From c577daa9e6d750f730b85ade0e4978072f2c4179 Mon Sep 17 00:00:00 2001 From: Kasper Date: Fri, 26 Sep 2025 21:39:11 +0200 Subject: [PATCH 001/184] feat: add basic UI2CB and CB2UI communication By pressing the button, the text in the input field is sent to the CB. Every second, UI receives the current time from CB. ref: N25B-107 ref: N25B-110 --- src/App.tsx | 68 ++++++++++++++++++++++++++++++++++------------------- 1 file changed, 44 insertions(+), 24 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 3d7ded3..e9ce59b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,35 +1,55 @@ -import { useState } from 'react' -import reactLogo from './assets/react.svg' -import viteLogo from '/vite.svg' +import { useState, useEffect } from 'react' import './App.css' function App() { - const [count, setCount] = useState(0) + const [message, setMessage] = useState(''); + const [sseMessage, setSseMessage] = useState(''); + + const sendMessage = async () => { + try { + const response = await fetch("http://localhost:8000/message", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ message }), + }); + const data = await response.json(); + console.log(data); + } catch (error) { + console.error("Error sending message: ", error); + } + }; + + useEffect(() => { + const eventSource = new EventSource("http://localhost:8000/sse"); + + eventSource.onmessage = (event) => { + setSseMessage(event.data); + }; + + return () => { + eventSource.close(); + }; + }); return ( - <> +
- - Vite logo - - - React logo - + setMessage(e.target.value)} + placeholder="Enter a message" + /> +
-

Vite + React

-
- -

- Edit src/App.tsx and save to test HMR -

+
+

Message from Server (SSE):

+

{sseMessage}

-

- Click on the Vite and React logos to learn more -

- - ) +
+ ); } export default App -- 2.49.1 From e076331cfcb4d36653297479ebe12cd85596a994 Mon Sep 17 00:00:00 2001 From: JGerla Date: Tue, 30 Sep 2025 13:32:45 +0200 Subject: [PATCH 002/184] feat: added ReactFlow-based node graph Added ReactFlow to dependencies (@xyflow/react). Added a basic reactflow template to test functionality of reactFlow and to build the visual programming UI on top of. ref: N25B-114 --- package-lock.json | 234 +++++++++++++++++++++++++- package.json | 1 + src/VisualProgrammingUI/VisProgUI.css | 0 src/VisualProgrammingUI/VisProgUI.tsx | 56 ++++++ src/main.tsx | 2 +- 5 files changed, 290 insertions(+), 3 deletions(-) create mode 100644 src/VisualProgrammingUI/VisProgUI.css create mode 100644 src/VisualProgrammingUI/VisProgUI.tsx diff --git a/package-lock.json b/package-lock.json index c11331b..c20c730 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "pepperplus-ui", "version": "0.0.0", "dependencies": { + "@xyflow/react": "^12.8.6", "react": "^19.1.1", "react-dom": "^19.1.1" }, @@ -1403,6 +1404,55 @@ "@babel/types": "^7.28.2" } }, + "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-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "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-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1421,7 +1471,7 @@ "version": "19.1.13", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.13.tgz", "integrity": "sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.0.2" @@ -1729,6 +1779,38 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@xyflow/react": { + "version": "12.8.6", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.8.6.tgz", + "integrity": "sha512-SksAm2m4ySupjChphMmzvm55djtgMDPr+eovPDdTnyGvShf73cvydfoBfWDFllooIQ4IaiUL5yfxHRwU0c37EA==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.70", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.70", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.70.tgz", + "integrity": "sha512-PpC//u9zxdjj0tfTSmZrg3+sRbTz6kop/Amky44U2Dl51sxzDTIUfXMwETOYpmr2dqICWXBIJwXL2a9QWtX2XA==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -1915,6 +1997,12 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -1968,9 +2056,114 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true, + "devOptional": true, "license": "MIT" }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -3249,6 +3442,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", + "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vite": { "version": "7.1.7", "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.7.tgz", @@ -3400,6 +3602,34 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "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 + } + } } } } diff --git a/package.json b/package.json index 6ed4958..2757ba3 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "preview": "vite preview" }, "dependencies": { + "@xyflow/react": "^12.8.6", "react": "^19.1.1", "react-dom": "^19.1.1" }, diff --git a/src/VisualProgrammingUI/VisProgUI.css b/src/VisualProgrammingUI/VisProgUI.css new file mode 100644 index 0000000..e69de29 diff --git a/src/VisualProgrammingUI/VisProgUI.tsx b/src/VisualProgrammingUI/VisProgUI.tsx new file mode 100644 index 0000000..2f58a64 --- /dev/null +++ b/src/VisualProgrammingUI/VisProgUI.tsx @@ -0,0 +1,56 @@ +import './VisProgUI.css' + +import { useState, useCallback } from 'react'; +import { + ReactFlow, + applyNodeChanges, + applyEdgeChanges, + addEdge, + type NodeChange, + type EdgeChange, + type Edge, type Connection +} from '@xyflow/react'; +import '@xyflow/react/dist/style.css'; + +const initialNodes = [ + {id: 'n1', position: {x: 0, y: 0}, data: {label: 'Node 1'}}, + {id: 'n2', position: {x: 0, y: 100}, data: {label: 'Node 2'}}, +]; +const initialEdges = [{id: 'n1-n2', source: 'n1', target: 'n2'}]; + +export default function App() { + const [nodes, setNodes] = useState(initialNodes); + const [edges, setEdges] = useState(initialEdges); + + const onNodesChange = useCallback( + (changes: NodeChange<{ + id: string; + position: { x: number; y: number; }; + data: { label: string; }; + }>[]) => setNodes((nodesSnapshot) => applyNodeChanges(changes, nodesSnapshot)), + [], + ); + const onEdgesChange = useCallback( + (changes: EdgeChange<{ id: string; source: string; target: string; }>[]) => setEdges((edgesSnapshot) => applyEdgeChanges(changes, edgesSnapshot)), + [], + ); + + + const onConnect = useCallback( + (params: Edge | Connection) => setEdges((edgesSnapshot) => addEdge(params, edgesSnapshot)), + [], + ); + + return ( +
+ +
+ ); +} \ No newline at end of file diff --git a/src/main.tsx b/src/main.tsx index bef5202..21c1bf7 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,7 +1,7 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import './index.css' -import App from './App.tsx' +import App from './VisualProgrammingUI/VisProgUI.tsx' createRoot(document.getElementById('root')!).render( -- 2.49.1 From 0eb5b65f6752b614200864a18a969156861cf7fc Mon Sep 17 00:00:00 2001 From: JGerla Date: Tue, 30 Sep 2025 14:53:53 +0200 Subject: [PATCH 003/184] feat: Added reconnectable edges Modified edges to support being disconnected and reconnected upon dragging their connection away from the currently connected node. ref: N25B-114 --- src/VisualProgrammingUI/VisProgUI.tsx | 75 +++++++++++++++++---------- src/main.tsx | 4 +- 2 files changed, 50 insertions(+), 29 deletions(-) diff --git a/src/VisualProgrammingUI/VisProgUI.tsx b/src/VisualProgrammingUI/VisProgUI.tsx index 2f58a64..40e858d 100644 --- a/src/VisualProgrammingUI/VisProgUI.tsx +++ b/src/VisualProgrammingUI/VisProgUI.tsx @@ -1,14 +1,17 @@ import './VisProgUI.css' -import { useState, useCallback } from 'react'; import { + useCallback, + useRef +} from 'react'; +import { + Background, + Controls, ReactFlow, - applyNodeChanges, - applyEdgeChanges, - addEdge, - type NodeChange, - type EdgeChange, - type Edge, type Connection + useNodesState, + useEdgesState, + reconnectEdge, + addEdge, type Edge, type Connection, } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; @@ -18,39 +21,55 @@ const initialNodes = [ ]; const initialEdges = [{id: 'n1-n2', source: 'n1', target: 'n2'}]; -export default function App() { - const [nodes, setNodes] = useState(initialNodes); - const [edges, setEdges] = useState(initialEdges); +const VisualProgrammingUI = ()=> { + const edgeReconnectSuccessful = useRef(true); + const [nodes, , onNodesChange] = useNodesState(initialNodes); + const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); + - const onNodesChange = useCallback( - (changes: NodeChange<{ - id: string; - position: { x: number; y: number; }; - data: { label: string; }; - }>[]) => setNodes((nodesSnapshot) => applyNodeChanges(changes, nodesSnapshot)), - [], - ); - const onEdgesChange = useCallback( - (changes: EdgeChange<{ id: string; source: string; target: string; }>[]) => setEdges((edgesSnapshot) => applyEdgeChanges(changes, edgesSnapshot)), - [], - ); - const onConnect = useCallback( - (params: Edge | Connection) => setEdges((edgesSnapshot) => addEdge(params, edgesSnapshot)), - [], + (params: Edge | Connection) => setEdges((els) => addEdge(params, els)), + [setEdges], ); + const onReconnectStart = useCallback(() => { + edgeReconnectSuccessful.current = false; + }, []); + + const onReconnect = useCallback((oldEdge: Edge, newConnection: Connection) => { + edgeReconnectSuccessful.current = true; + setEdges((els) => reconnectEdge(oldEdge, newConnection, els)); + }, [setEdges]); + + const onReconnectEnd = useCallback((_: unknown, edge: { id: string; }) => { + if (!edgeReconnectSuccessful.current) { + setEdges((eds) => eds.filter((e) => e.id !== edge.id)); + } + + edgeReconnectSuccessful.current = true; + }, [setEdges]); + return ( -
+
+ attributionPosition="top-right" + > + + +
); -} \ No newline at end of file +}; + +export default VisualProgrammingUI \ No newline at end of file diff --git a/src/main.tsx b/src/main.tsx index 21c1bf7..9d67972 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,10 +1,12 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import './index.css' -import App from './VisualProgrammingUI/VisProgUI.tsx' +import App from './App.tsx' +import VisualProgrammingUI from "./VisualProgrammingUI/VisProgUI.tsx"; createRoot(document.getElementById('root')!).render( + , ) -- 2.49.1 From 10e5db057b6caa5a3c5bf0cad56eba3519b879b5 Mon Sep 17 00:00:00 2001 From: JGerla Date: Tue, 30 Sep 2025 15:20:16 +0200 Subject: [PATCH 004/184] feat: updated styles of visProgUI Added a rounded outline to the editor and changed the edge to be an arrow so program-flow can be interpreted more easily from the UI. ref: N25B-114 --- src/VisualProgrammingUI/VisProgUI.tsx | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/src/VisualProgrammingUI/VisProgUI.tsx b/src/VisualProgrammingUI/VisProgUI.tsx index 40e858d..0df6a4c 100644 --- a/src/VisualProgrammingUI/VisProgUI.tsx +++ b/src/VisualProgrammingUI/VisProgUI.tsx @@ -11,16 +11,28 @@ import { useNodesState, useEdgesState, reconnectEdge, - addEdge, type Edge, type Connection, + addEdge, + MarkerType, + type Edge, + type Connection, } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; + const initialNodes = [ - {id: 'n1', position: {x: 0, y: 0}, data: {label: 'Node 1'}}, - {id: 'n2', position: {x: 0, y: 100}, data: {label: 'Node 2'}}, + {id: 'n1', position: {x: 0, y: 0}, data: {label: 'Start'}}, + {id: 'n2', position: {x: 0, y: 100}, data: {label: 'End'}}, ]; const initialEdges = [{id: 'n1-n2', source: 'n1', target: 'n2'}]; +const defaultEdgeOptions = { + type: 'floating', + markerEnd: { + type: MarkerType.ArrowClosed, + color: '#505050', + }, +}; + const VisualProgrammingUI = ()=> { const edgeReconnectSuccessful = useRef(true); const [nodes, , onNodesChange] = useNodesState(initialNodes); @@ -51,10 +63,11 @@ const VisualProgrammingUI = ()=> { }, [setEdges]); return ( -
+
{ onReconnectEnd={onReconnectEnd} onConnect={onConnect} fitView - attributionPosition="top-right" + proOptions={{hideAttribution: true }} > -- 2.49.1 From 63f97de164d20b275855c20f2a9ac5276803121a Mon Sep 17 00:00:00 2001 From: JobvAlewijk Date: Tue, 30 Sep 2025 16:03:02 +0200 Subject: [PATCH 005/184] feat: link and directory improvements Changing pages now happens via Link, directory has gotten a layout change with app becoming a starting point and every page requireing their own .tsx and .css file. Components have also been added where reocurring componennts can be made and reapplied in the project. ref: N25B-96 --- package-lock.json | 52 +++++++++++++++++++++- package.json | 3 +- src/App.css | 32 +++++++++++-- src/App.tsx | 37 ++++----------- {public => src/assets}/vite.svg | 0 src/components/components.tsx | 20 +++++++++ src/main.tsx | 5 ++- src/pages/Home/Home.module.css | 7 +++ src/pages/Home/Home.tsx | 42 +++++++++++++++++ src/pages/TemplatePage/Template.module.css | 4 ++ src/pages/TemplatePage/Template.tsx | 24 ++++++++++ 11 files changed, 191 insertions(+), 35 deletions(-) rename {public => src/assets}/vite.svg (100%) create mode 100644 src/components/components.tsx create mode 100644 src/pages/Home/Home.module.css create mode 100644 src/pages/Home/Home.tsx create mode 100644 src/pages/TemplatePage/Template.module.css create mode 100644 src/pages/TemplatePage/Template.tsx diff --git a/package-lock.json b/package-lock.json index c11331b..17ef9d3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "0.0.0", "dependencies": { "react": "^19.1.1", - "react-dom": "^19.1.1" + "react-dom": "^19.1.1", + "react-router": "^7.9.3" }, "devDependencies": { "@eslint/js": "^9.36.0", @@ -56,6 +57,7 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -1423,6 +1425,7 @@ "integrity": "sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -1483,6 +1486,7 @@ "integrity": "sha512-EHrrEsyhOhxYt8MTg4zTF+DJMuNBzWwgvvOYNj/zm1vnaD/IC5zCXFehZv94Piqa2cRFfXrTFxIvO95L7Qc/cw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.44.1", "@typescript-eslint/types": "8.44.1", @@ -1735,6 +1739,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1853,6 +1858,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001741", @@ -1949,6 +1955,15 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2074,6 +2089,7 @@ "integrity": "sha512-hB4FIzXovouYzwzECDcUkJ4OcfOEkXTv2zRY6B9bkwjx/cprAq0uvm1nl7zvQ0/TsUk0zQiN4uPfJpB9m+rPMQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -2895,6 +2911,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -2904,6 +2921,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz", "integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -2921,6 +2939,28 @@ "node": ">=0.10.0" } }, + "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==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -3024,6 +3064,12 @@ "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", + "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -3124,6 +3170,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -3176,6 +3223,7 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3255,6 +3303,7 @@ "integrity": "sha512-VbA8ScMvAISJNJVbRDTJdCwqQoAareR/wutevKanhR2/1EkoXVZVkkORaYm/tNVCjP/UDTKtcw3bAkwOUdedmA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -3348,6 +3397,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, diff --git a/package.json b/package.json index 6ed4958..6070bd8 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ }, "dependencies": { "react": "^19.1.1", - "react-dom": "^19.1.1" + "react-dom": "^19.1.1", + "react-router": "^7.9.3" }, "devDependencies": { "@eslint/js": "^9.36.0", diff --git a/src/App.css b/src/App.css index b9d355d..95100ed 100644 --- a/src/App.css +++ b/src/App.css @@ -33,10 +33,34 @@ } } -.card { - padding: 2em; + + +button.reset { + background-color: crimson; + color: white; +} +button.reset:hover { + background-color: darkred; } -.read-the-docs { - color: #888; +button.movePage { + position: fixed; /* Position the button relative to the viewport (screen), + not inside its parent. It stays in place even when you scroll. */ + + top: 50%; /* Place the button halfway down from the top of the screen. */ + /* Stick the button to the left edge of the screen. */ + border: 3px solid black; + outline: 1px solid white; + transform: translateY(-50%); + background-color: aquamarine; + color: white; +} +button.movePage.left{ + left: 5%; +} +button.movePage.right{ + right: 5%; +} +button.movePage:hover{ + background-color: rgb(0, 176, 176); } diff --git a/src/App.tsx b/src/App.tsx index 3d7ded3..fe7cb61 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,34 +1,15 @@ -import { useState } from 'react' -import reactLogo from './assets/react.svg' -import viteLogo from '/vite.svg' +import { Routes, Route } from 'react-router' import './App.css' +import TemplatePage from './pages/TemplatePage/Template.tsx' +import Home from './pages/Home/Home.tsx' -function App() { - const [count, setCount] = useState(0) - +function App(){ + return ( - <> - -

Vite + React

-
- -

- Edit src/App.tsx and save to test HMR -

-
-

- Click on the Vite and React logos to learn more -

- + + } /> + } /> + ) } diff --git a/public/vite.svg b/src/assets/vite.svg similarity index 100% rename from public/vite.svg rename to src/assets/vite.svg diff --git a/src/components/components.tsx b/src/components/components.tsx new file mode 100644 index 0000000..d323843 --- /dev/null +++ b/src/components/components.tsx @@ -0,0 +1,20 @@ +// src/components/Counter.tsx +import { useState } from 'react' +//import style from './Counter.module.css' // optional, if you want a CSS module for reset button +import '../App.css' + +function Counter() { + const [count, setCount] = useState(0) + + return ( +
+ + +
+ ) +} +export default Counter \ No newline at end of file diff --git a/src/main.tsx b/src/main.tsx index bef5202..ae3d161 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,10 +1,13 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' +import { BrowserRouter } from 'react-router' import './index.css' import App from './App.tsx' createRoot(document.getElementById('root')!).render( - + + + , ) diff --git a/src/pages/Home/Home.module.css b/src/pages/Home/Home.module.css new file mode 100644 index 0000000..c3b1328 --- /dev/null +++ b/src/pages/Home/Home.module.css @@ -0,0 +1,7 @@ + +.read_the_docs { + color: #888; +} +.card { + padding: 2em; +} \ No newline at end of file diff --git a/src/pages/Home/Home.tsx b/src/pages/Home/Home.tsx new file mode 100644 index 0000000..fae9861 --- /dev/null +++ b/src/pages/Home/Home.tsx @@ -0,0 +1,42 @@ +import { useState } from 'react' +import { Link } from 'react-router' +import reactLogo from '../../assets/react.svg' +import viteLogo from '../../assets/vite.svg' +import style from './Home.module.css' +import Counter from '../../components/components.tsx' + +function Home() { + + + + return ( + <> + +

Vite + React

+ + + + + + +

+ Edit src/App.tsx and save to test HMR +

+ +

+ Click on the Vite and React logos to learn more +

+ + ) +} + +export default Home \ No newline at end of file diff --git a/src/pages/TemplatePage/Template.module.css b/src/pages/TemplatePage/Template.module.css new file mode 100644 index 0000000..8526661 --- /dev/null +++ b/src/pages/TemplatePage/Template.module.css @@ -0,0 +1,4 @@ +button.reset:hover { + background-color: yellow; +} + diff --git a/src/pages/TemplatePage/Template.tsx b/src/pages/TemplatePage/Template.tsx new file mode 100644 index 0000000..4cb3118 --- /dev/null +++ b/src/pages/TemplatePage/Template.tsx @@ -0,0 +1,24 @@ +import { useState } from 'react' +import { Link } from 'react-router' +import Counter from '../../components/components.tsx' +import style from './Template.module.css' + +//this is your css file where you can style your buttons and such +//you can still use css parts from App.css, but also overwrite them + +function TemplatePage() { + + + return ( + <> + + {/* here you link to the homepage, in App.tsx you can link new pages */} + + + + ) +} + +export default TemplatePage \ No newline at end of file -- 2.49.1 From e098ffebd6c3d2ec8c819d7cd6a41d137eb6c928 Mon Sep 17 00:00:00 2001 From: JGerla Date: Tue, 30 Sep 2025 16:13:48 +0200 Subject: [PATCH 006/184] feat: added a custom start node Defined a basic start node; it does not contain any further functionality but does provide a basis for implementing future custom nodes. ref: N25B-114 --- src/main.tsx | 2 +- .../VisProgUI.css | 0 .../VisProgUI.tsx | 27 ++++++++++++++++--- .../components/StartNode.tsx | 15 +++++++++++ 4 files changed, 40 insertions(+), 4 deletions(-) rename src/{VisualProgrammingUI => visualProgrammingUI}/VisProgUI.css (100%) rename src/{VisualProgrammingUI => visualProgrammingUI}/VisProgUI.tsx (79%) create mode 100644 src/visualProgrammingUI/components/StartNode.tsx diff --git a/src/main.tsx b/src/main.tsx index 9d67972..c2e7460 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,7 +2,7 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import './index.css' import App from './App.tsx' -import VisualProgrammingUI from "./VisualProgrammingUI/VisProgUI.tsx"; +import VisualProgrammingUI from "./visualProgrammingUI/VisProgUI.tsx"; createRoot(document.getElementById('root')!).render( diff --git a/src/VisualProgrammingUI/VisProgUI.css b/src/visualProgrammingUI/VisProgUI.css similarity index 100% rename from src/VisualProgrammingUI/VisProgUI.css rename to src/visualProgrammingUI/VisProgUI.css diff --git a/src/VisualProgrammingUI/VisProgUI.tsx b/src/visualProgrammingUI/VisProgUI.tsx similarity index 79% rename from src/VisualProgrammingUI/VisProgUI.tsx rename to src/visualProgrammingUI/VisProgUI.tsx index 0df6a4c..d71af49 100644 --- a/src/VisualProgrammingUI/VisProgUI.tsx +++ b/src/visualProgrammingUI/VisProgUI.tsx @@ -17,13 +17,33 @@ import { type Connection, } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; +import StartNode from './components/StartNode.tsx'; +const nodeTypes = { + startNode: StartNode, +}; const initialNodes = [ - {id: 'n1', position: {x: 0, y: 0}, data: {label: 'Start'}}, - {id: 'n2', position: {x: 0, y: 100}, data: {label: 'End'}}, + { + id: 'start', + type: 'startNode', + position: {x: 0, y: 0}, + data: {label: 'Start'}, + }, + { + id: 'genericPhase', + type: 'default', + position: {x: 0, y: 150}, + data: {label: 'Generic Phase'}, + }, + { + id: 'end', + type: 'output', + position: {x: 0, y: 300}, + data: {label: 'End'} + } ]; -const initialEdges = [{id: 'n1-n2', source: 'n1', target: 'n2'}]; +const initialEdges = [{id: 'start-end', source: 'start', target: 'end'}]; const defaultEdgeOptions = { type: 'floating', @@ -70,6 +90,7 @@ const VisualProgrammingUI = ()=> { defaultEdgeOptions={defaultEdgeOptions} onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} + nodeTypes={nodeTypes} snapToGrid onReconnect={onReconnect} onReconnectStart={onReconnectStart} diff --git a/src/visualProgrammingUI/components/StartNode.tsx b/src/visualProgrammingUI/components/StartNode.tsx new file mode 100644 index 0000000..d92ede1 --- /dev/null +++ b/src/visualProgrammingUI/components/StartNode.tsx @@ -0,0 +1,15 @@ +import { Handle, Position } from '@xyflow/react'; +import '@xyflow/react/dist/style.css'; + +// @ts-ignore +export const StartNode = ({ data }) => { + return ( + <> +
+
data test {data.label}
+ +
+ + ); +}; +export default StartNode; \ No newline at end of file -- 2.49.1 From 427137eae15d0435984528fad6d64f80334a24c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Tue, 30 Sep 2025 17:48:32 +0200 Subject: [PATCH 007/184] feat: added pepper logo and link. ref: N25B-79 --- package-lock.json | 12 - src/App.css | 49 + src/assets/pepper transp2 small.svg | 1241 +++++++++++ .../pepper transp2 small.svg:Zone.Identifier | 4 + src/assets/pepper transp2.svg | 1882 +++++++++++++++++ src/pages/Home/Home.module.css | 3 +- src/pages/Home/Home.tsx | 7 + 7 files changed, 3184 insertions(+), 14 deletions(-) create mode 100644 src/assets/pepper transp2 small.svg create mode 100644 src/assets/pepper transp2 small.svg:Zone.Identifier create mode 100644 src/assets/pepper transp2.svg diff --git a/package-lock.json b/package-lock.json index 17ef9d3..0b92f5c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -57,7 +57,6 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -1425,7 +1424,6 @@ "integrity": "sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -1486,7 +1484,6 @@ "integrity": "sha512-EHrrEsyhOhxYt8MTg4zTF+DJMuNBzWwgvvOYNj/zm1vnaD/IC5zCXFehZv94Piqa2cRFfXrTFxIvO95L7Qc/cw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.44.1", "@typescript-eslint/types": "8.44.1", @@ -1739,7 +1736,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1858,7 +1854,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001741", @@ -2089,7 +2084,6 @@ "integrity": "sha512-hB4FIzXovouYzwzECDcUkJ4OcfOEkXTv2zRY6B9bkwjx/cprAq0uvm1nl7zvQ0/TsUk0zQiN4uPfJpB9m+rPMQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -2911,7 +2905,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -2921,7 +2914,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz", "integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -3170,7 +3162,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3223,7 +3214,6 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3303,7 +3293,6 @@ "integrity": "sha512-VbA8ScMvAISJNJVbRDTJdCwqQoAareR/wutevKanhR2/1EkoXVZVkkORaYm/tNVCjP/UDTKtcw3bAkwOUdedmA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -3397,7 +3386,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, diff --git a/src/App.css b/src/App.css index 95100ed..dcb46cf 100644 --- a/src/App.css +++ b/src/App.css @@ -18,6 +18,20 @@ filter: drop-shadow(0 0 2em #61dafbaa); } + +.logopepper { + height: 8em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} +.logopepper:hover { + filter: drop-shadow(0 0 10em #ff0707); +} +.logopepper.react:hover { + filter: drop-shadow(0 0 10em #4eff14aa); +} + @keyframes logo-spin { from { transform: rotate(0deg); @@ -27,12 +41,47 @@ } } + +@keyframes logo-pepper-spin { + from { + transform: rotate(-20deg); + } + to { + transform: rotate(20deg); + } +} + + + +@keyframes logo-pepper-scale { + from { + transform: scale(1,1); + } + to { + transform: scale(1.5,1.5); + } +} + @media (prefers-reduced-motion: no-preference) { a:nth-of-type(2) .logo { animation: logo-spin infinite 20s linear; } } +@media (prefers-reduced-motion: no-preference) { + .logopepper { + animation: logo-pepper-spin infinite 1s linear alternate; + } +} + +@media (prefers-reduced-motion: no-preference) { + .logoPepperScaling { + animation: logo-pepper-scale infinite 1s linear alternate; + } +} + + + button.reset { diff --git a/src/assets/pepper transp2 small.svg b/src/assets/pepper transp2 small.svg new file mode 100644 index 0000000..242dd63 --- /dev/null +++ b/src/assets/pepper transp2 small.svg @@ -0,0 +1,1241 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/pepper transp2 small.svg:Zone.Identifier b/src/assets/pepper transp2 small.svg:Zone.Identifier new file mode 100644 index 0000000..7659fa2 --- /dev/null +++ b/src/assets/pepper transp2 small.svg:Zone.Identifier @@ -0,0 +1,4 @@ +[ZoneTransfer] +ZoneId=3 +ReferrerUrl=https://www.freeconvert.com/png-to-svg/download +HostUrl=https://s131-isny.freeconvert.com/task/68dbf757ff1f2049f1df2a85/pepper%20transp2%20small.svg diff --git a/src/assets/pepper transp2.svg b/src/assets/pepper transp2.svg new file mode 100644 index 0000000..41c502b --- /dev/null +++ b/src/assets/pepper transp2.svg @@ -0,0 +1,1882 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/pages/Home/Home.module.css b/src/pages/Home/Home.module.css index c3b1328..aed0f27 100644 --- a/src/pages/Home/Home.module.css +++ b/src/pages/Home/Home.module.css @@ -1,7 +1,6 @@ - .read_the_docs { color: #888; } .card { padding: 2em; -} \ No newline at end of file +} diff --git a/src/pages/Home/Home.tsx b/src/pages/Home/Home.tsx index fae9861..2b9315f 100644 --- a/src/pages/Home/Home.tsx +++ b/src/pages/Home/Home.tsx @@ -2,6 +2,7 @@ import { useState } from 'react' import { Link } from 'react-router' import reactLogo from '../../assets/react.svg' import viteLogo from '../../assets/vite.svg' +import pepperLogo from '../../assets/pepper transp2 small.svg' import style from './Home.module.css' import Counter from '../../components/components.tsx' @@ -12,6 +13,12 @@ function Home() { return ( <>
+
+ + Pepper logo + +
+ Vite logo -- 2.49.1 From c512739a25b29f9631db23014f7a0dd40ac33496 Mon Sep 17 00:00:00 2001 From: Twirre Meulenbelt <43213592+TwirreM@users.noreply.github.com> Date: Wed, 1 Oct 2025 10:17:16 +0200 Subject: [PATCH 008/184] feat: differentiate between SSE messages For spoken text, we have JSON data that can be differentiated from other data. We show this spoken text in a different UI field. ref: N25B-110 --- src/App.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/App.tsx b/src/App.tsx index e9ce59b..53505d5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,7 @@ import './App.css' function App() { const [message, setMessage] = useState(''); const [sseMessage, setSseMessage] = useState(''); + const [spoken, setSpoken] = useState(""); const sendMessage = async () => { try { @@ -26,12 +27,17 @@ function App() { eventSource.onmessage = (event) => { setSseMessage(event.data); + + try { + const data = JSON.parse(event.data); + if (data.speech) setSpoken(data.speech); + } catch {} }; return () => { eventSource.close(); }; - }); + }, []); return (
@@ -40,6 +46,7 @@ function App() { type="text" value={message} onChange={(e) => setMessage(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && sendMessage().then(() => setMessage(""))} placeholder="Enter a message" /> @@ -48,6 +55,10 @@ function App() {

Message from Server (SSE):

{sseMessage}

+
+

Spoken text (SSE):

+

{spoken}

+
); } -- 2.49.1 From b991e92c37bce47faa9e1dc43e191ab48cef7157 Mon Sep 17 00:00:00 2001 From: JGerla Date: Wed, 1 Oct 2025 10:38:35 +0200 Subject: [PATCH 009/184] feat: changed startNode to use custom type for data changed to startNode file to be a general file for custom node types, and created a custom type for the data property of StartNode. BREAKING: renamed StartNode.tsx to NodeDefinitions.tsx ref: N25B-114 --- src/visualProgrammingUI/VisProgUI.css | 7 +++++++ src/visualProgrammingUI/VisProgUI.tsx | 4 ++-- .../{StartNode.tsx => NodeDefinitions.tsx} | 13 ++++++++++--- 3 files changed, 19 insertions(+), 5 deletions(-) rename src/visualProgrammingUI/components/{StartNode.tsx => NodeDefinitions.tsx} (51%) diff --git a/src/visualProgrammingUI/VisProgUI.css b/src/visualProgrammingUI/VisProgUI.css index e69de29..35645a7 100644 --- a/src/visualProgrammingUI/VisProgUI.css +++ b/src/visualProgrammingUI/VisProgUI.css @@ -0,0 +1,7 @@ +.default-node { + padding: 10px 20px; + background-color: white; + outline-style: solid; + border-radius: 5pt; + outline-width: 1pt; +} \ No newline at end of file diff --git a/src/visualProgrammingUI/VisProgUI.tsx b/src/visualProgrammingUI/VisProgUI.tsx index d71af49..ae526c6 100644 --- a/src/visualProgrammingUI/VisProgUI.tsx +++ b/src/visualProgrammingUI/VisProgUI.tsx @@ -17,7 +17,7 @@ import { type Connection, } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; -import StartNode from './components/StartNode.tsx'; +import StartNode from "./components/NodeDefinitions.tsx"; const nodeTypes = { startNode: StartNode, @@ -28,7 +28,7 @@ const initialNodes = [ id: 'start', type: 'startNode', position: {x: 0, y: 0}, - data: {label: 'Start'}, + data: {label: 'start'} }, { id: 'genericPhase', diff --git a/src/visualProgrammingUI/components/StartNode.tsx b/src/visualProgrammingUI/components/NodeDefinitions.tsx similarity index 51% rename from src/visualProgrammingUI/components/StartNode.tsx rename to src/visualProgrammingUI/components/NodeDefinitions.tsx index d92ede1..e9fc0b9 100644 --- a/src/visualProgrammingUI/components/StartNode.tsx +++ b/src/visualProgrammingUI/components/NodeDefinitions.tsx @@ -1,12 +1,19 @@ import { Handle, Position } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; +import '../VisProgUI.css'; -// @ts-ignore -export const StartNode = ({ data }) => { +// Datatypes for NodeTypes + +type startNodeData = { label: string; }; + +// Definitions of Nodes + + +export const StartNode= ({ data } : {data : startNodeData}) => { return ( <>
-
data test {data.label}
+
data test {data.label}
-- 2.49.1 From 54b5935829974f19ef95325b87610524a8234a46 Mon Sep 17 00:00:00 2001 From: JGerla Date: Wed, 1 Oct 2025 10:55:24 +0200 Subject: [PATCH 010/184] feat: created new nodes and a default nodeType added a type for defaultNodeData, this can house common data that all nodes should have. the other types can build on this defaultData. Also added an endNode and phaseNode to NodeDefinitions.tsx, together with a nodeData type for each new node type. ref: N25B-114 --- src/visualProgrammingUI/VisProgUI.tsx | 18 ++++++--- .../components/NodeDefinitions.tsx | 37 ++++++++++++++++++- 2 files changed, 47 insertions(+), 8 deletions(-) diff --git a/src/visualProgrammingUI/VisProgUI.tsx b/src/visualProgrammingUI/VisProgUI.tsx index ae526c6..48c987f 100644 --- a/src/visualProgrammingUI/VisProgUI.tsx +++ b/src/visualProgrammingUI/VisProgUI.tsx @@ -17,28 +17,34 @@ import { type Connection, } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; -import StartNode from "./components/NodeDefinitions.tsx"; +import { + StartNode, + EndNode, + PhaseNode +} from "./components/NodeDefinitions.tsx"; const nodeTypes = { - startNode: StartNode, + start: StartNode, + end: EndNode, + phase: PhaseNode, }; const initialNodes = [ { id: 'start', - type: 'startNode', + type: 'start', position: {x: 0, y: 0}, data: {label: 'start'} }, { id: 'genericPhase', - type: 'default', + type: 'phase', position: {x: 0, y: 150}, - data: {label: 'Generic Phase'}, + data: {label: 'Generic Phase', number: 1}, }, { id: 'end', - type: 'output', + type: 'end', position: {x: 0, y: 300}, data: {label: 'End'} } diff --git a/src/visualProgrammingUI/components/NodeDefinitions.tsx b/src/visualProgrammingUI/components/NodeDefinitions.tsx index e9fc0b9..c3c011b 100644 --- a/src/visualProgrammingUI/components/NodeDefinitions.tsx +++ b/src/visualProgrammingUI/components/NodeDefinitions.tsx @@ -4,7 +4,17 @@ import '../VisProgUI.css'; // Datatypes for NodeTypes -type startNodeData = { label: string; }; +type defaultNodeData = { + label: string; +}; + +type startNodeData = defaultNodeData; +type endNodeData = defaultNodeData; +type phaseNodeData = defaultNodeData & { + number: number; +}; + +export type nodeData = defaultNodeData | startNodeData | phaseNodeData | endNodeData; // Definitions of Nodes @@ -19,4 +29,27 @@ export const StartNode= ({ data } : {data : startNodeData}) => { ); }; -export default StartNode; \ No newline at end of file + + +export const EndNode= ({ data } : {data : endNodeData}) => { + return ( + <> +
+
{data.label}
+ +
+ + ); +}; + +export const PhaseNode= ({ data } : {data : phaseNodeData}) => { + return ( + <> +
+
phase {data.number} {data.label}
+ + +
+ + ); +}; -- 2.49.1 From 79f0827b39f9acb100276e59d2d54370618a4bbb Mon Sep 17 00:00:00 2001 From: JGerla Date: Wed, 1 Oct 2025 10:55:24 +0200 Subject: [PATCH 011/184] feat: created new nodes and a default nodeType added a type for defaultNodeData, this can house common data that all nodes should have. the other types can build on this defaultData. Also added an endNode and phaseNode to NodeDefinitions.tsx, together with a nodeData type for each new node type. ref: N25B-114 --- src/visualProgrammingUI/VisProgUI.tsx | 18 +++++--- .../components/NodeDefinitions.tsx | 41 +++++++++++++++++-- 2 files changed, 49 insertions(+), 10 deletions(-) diff --git a/src/visualProgrammingUI/VisProgUI.tsx b/src/visualProgrammingUI/VisProgUI.tsx index ae526c6..48c987f 100644 --- a/src/visualProgrammingUI/VisProgUI.tsx +++ b/src/visualProgrammingUI/VisProgUI.tsx @@ -17,28 +17,34 @@ import { type Connection, } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; -import StartNode from "./components/NodeDefinitions.tsx"; +import { + StartNode, + EndNode, + PhaseNode +} from "./components/NodeDefinitions.tsx"; const nodeTypes = { - startNode: StartNode, + start: StartNode, + end: EndNode, + phase: PhaseNode, }; const initialNodes = [ { id: 'start', - type: 'startNode', + type: 'start', position: {x: 0, y: 0}, data: {label: 'start'} }, { id: 'genericPhase', - type: 'default', + type: 'phase', position: {x: 0, y: 150}, - data: {label: 'Generic Phase'}, + data: {label: 'Generic Phase', number: 1}, }, { id: 'end', - type: 'output', + type: 'end', position: {x: 0, y: 300}, data: {label: 'End'} } diff --git a/src/visualProgrammingUI/components/NodeDefinitions.tsx b/src/visualProgrammingUI/components/NodeDefinitions.tsx index e9fc0b9..99ad1da 100644 --- a/src/visualProgrammingUI/components/NodeDefinitions.tsx +++ b/src/visualProgrammingUI/components/NodeDefinitions.tsx @@ -4,7 +4,17 @@ import '../VisProgUI.css'; // Datatypes for NodeTypes -type startNodeData = { label: string; }; +type defaultNodeData = { + label: string; +}; + +type startNodeData = defaultNodeData; +type endNodeData = defaultNodeData; +type phaseNodeData = defaultNodeData & { + number: number; +}; + +export type nodeData = defaultNodeData | startNodeData | phaseNodeData | endNodeData; // Definitions of Nodes @@ -12,11 +22,34 @@ type startNodeData = { label: string; }; export const StartNode= ({ data } : {data : startNodeData}) => { return ( <> -
-
data test {data.label}
+
+
data test {data.label}
); }; -export default StartNode; \ No newline at end of file + + +export const EndNode= ({ data } : {data : endNodeData}) => { + return ( + <> +
+
{data.label}
+ +
+ + ); +}; + +export const PhaseNode= ({ data } : {data : phaseNodeData}) => { + return ( + <> +
+
phase {data.number} {data.label}
+ + +
+ + ); +}; -- 2.49.1 From 1d22ea38789af3fa051e64a73c680e2cd57c36a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Wed, 1 Oct 2025 13:22:58 +0200 Subject: [PATCH 012/184] fix: fixed naming for pepper logo --- src/assets/pepper transp2 small.svg:Zone.Identifier | 4 ---- src/assets/{pepper transp2.svg => pepper_transp2.svg} | 0 .../{pepper transp2 small.svg => pepper_transp2_small.svg} | 0 src/pages/Home/Home.tsx | 2 +- 4 files changed, 1 insertion(+), 5 deletions(-) delete mode 100644 src/assets/pepper transp2 small.svg:Zone.Identifier rename src/assets/{pepper transp2.svg => pepper_transp2.svg} (100%) rename src/assets/{pepper transp2 small.svg => pepper_transp2_small.svg} (100%) diff --git a/src/assets/pepper transp2 small.svg:Zone.Identifier b/src/assets/pepper transp2 small.svg:Zone.Identifier deleted file mode 100644 index 7659fa2..0000000 --- a/src/assets/pepper transp2 small.svg:Zone.Identifier +++ /dev/null @@ -1,4 +0,0 @@ -[ZoneTransfer] -ZoneId=3 -ReferrerUrl=https://www.freeconvert.com/png-to-svg/download -HostUrl=https://s131-isny.freeconvert.com/task/68dbf757ff1f2049f1df2a85/pepper%20transp2%20small.svg diff --git a/src/assets/pepper transp2.svg b/src/assets/pepper_transp2.svg similarity index 100% rename from src/assets/pepper transp2.svg rename to src/assets/pepper_transp2.svg diff --git a/src/assets/pepper transp2 small.svg b/src/assets/pepper_transp2_small.svg similarity index 100% rename from src/assets/pepper transp2 small.svg rename to src/assets/pepper_transp2_small.svg diff --git a/src/pages/Home/Home.tsx b/src/pages/Home/Home.tsx index 2b9315f..5c4a72d 100644 --- a/src/pages/Home/Home.tsx +++ b/src/pages/Home/Home.tsx @@ -2,7 +2,7 @@ import { useState } from 'react' import { Link } from 'react-router' import reactLogo from '../../assets/react.svg' import viteLogo from '../../assets/vite.svg' -import pepperLogo from '../../assets/pepper transp2 small.svg' +import pepperLogo from '../../assets/pepper_transp2_small.svg' import style from './Home.module.css' import Counter from '../../components/components.tsx' -- 2.49.1 From 9df46c90a379ab8b6fa77836610d89c93661b01a Mon Sep 17 00:00:00 2001 From: JGerla Date: Wed, 1 Oct 2025 13:29:32 +0200 Subject: [PATCH 013/184] feat: added drag and drop menu for adding new nodes to flow added a sidebar that supports drag and dropping new nodes from the sidebar into the flow editor. also added a new package (neodrag) for easy draggable behavior outside the reactFlow editor. ref: N25B-114 --- package-lock.json | 7 ++ package.json | 1 + src/visualProgrammingUI/VisProgUI.tsx | 59 ++++++---- .../components/DragDropSidebar.tsx | 104 ++++++++++++++++++ 4 files changed, 150 insertions(+), 21 deletions(-) create mode 100644 src/visualProgrammingUI/components/DragDropSidebar.tsx diff --git a/package-lock.json b/package-lock.json index c20c730..81c5e48 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "pepperplus-ui", "version": "0.0.0", "dependencies": { + "@neodrag/react": "^2.3.1", "@xyflow/react": "^12.8.6", "react": "^19.1.1", "react-dom": "^19.1.1" @@ -1006,6 +1007,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@neodrag/react": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@neodrag/react/-/react-2.3.1.tgz", + "integrity": "sha512-mOVefo3mFmaVLs9PB5F5wMXnnclG81qjOaPHyf8YZUnw/Ciz0pAqyJDwDJk0nPTIK5I2x1JdjXSchGNdCxZNRQ==", + "license": "MIT" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", diff --git a/package.json b/package.json index 2757ba3..cbea372 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "preview": "vite preview" }, "dependencies": { + "@neodrag/react": "^2.3.1", "@xyflow/react": "^12.8.6", "react": "^19.1.1", "react-dom": "^19.1.1" diff --git a/src/visualProgrammingUI/VisProgUI.tsx b/src/visualProgrammingUI/VisProgUI.tsx index 48c987f..55c5a85 100644 --- a/src/visualProgrammingUI/VisProgUI.tsx +++ b/src/visualProgrammingUI/VisProgUI.tsx @@ -8,6 +8,7 @@ import { Background, Controls, ReactFlow, + ReactFlowProvider, useNodesState, useEdgesState, reconnectEdge, @@ -23,6 +24,8 @@ import { PhaseNode } from "./components/NodeDefinitions.tsx"; +import { Sidebar } from './components/DragDropSidebar.tsx'; + const nodeTypes = { start: StartNode, end: EndNode, @@ -59,7 +62,7 @@ const defaultEdgeOptions = { }, }; -const VisualProgrammingUI = ()=> { +const VisProgUI = ()=> { const edgeReconnectSuccessful = useRef(true); const [nodes, , onNodesChange] = useNodesState(initialNodes); const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); @@ -89,27 +92,41 @@ const VisualProgrammingUI = ()=> { }, [setEdges]); return ( -
- - - - +
+
+ + + + +
+
+ +
+ ); }; -export default VisualProgrammingUI \ No newline at end of file +function VisualProgrammingUI(){ + return ( + + + + ); +} + +export default VisualProgrammingUI; \ No newline at end of file diff --git a/src/visualProgrammingUI/components/DragDropSidebar.tsx b/src/visualProgrammingUI/components/DragDropSidebar.tsx new file mode 100644 index 0000000..9c1e3dd --- /dev/null +++ b/src/visualProgrammingUI/components/DragDropSidebar.tsx @@ -0,0 +1,104 @@ +import { useDraggable } from '@neodrag/react'; +import { + useReactFlow, + type XYPosition +} from '@xyflow/react'; +import { + type ReactNode, + useCallback, + useRef, + useState +} from 'react'; + + +// improve later to create better automatic IDs +let id = 0; +const getId = () => `dndnode_${id++}`; + + +interface DraggableNodeProps { + className?: string; + children: ReactNode; + nodeType: string; + onDrop: (nodeType: string, position: XYPosition) => void; +} + +function DraggableNode({ className, children, nodeType, onDrop }: DraggableNodeProps) { + const draggableRef = useRef(null); + const [position, setPosition] = useState({ x: 0, y: 0 }); + + + // @ts-ignore + useDraggable(draggableRef, { + position: position, + onDrag: ({ offsetX, offsetY }) => { + // Calculate position relative to the viewport + setPosition({ + x: offsetX, + y: offsetY, + }); + }, + onDragEnd: ({ event }) => { + setPosition({ x: 0, y: 0 }); + onDrop(nodeType, { + x: event.clientX, + y: event.clientY, + }); + }, + }); + + return ( +
+ {children} +
+ ); +} + +export function Sidebar() { + const { setNodes, screenToFlowPosition } = useReactFlow(); + + const handleNodeDrop = useCallback( + (nodeType: string, screenPosition: XYPosition) => { + const flow = document.querySelector('.react-flow'); + const flowRect = flow?.getBoundingClientRect(); + const isInFlow = + flowRect && + screenPosition.x >= flowRect.left && + screenPosition.x <= flowRect.right && + screenPosition.y >= flowRect.top && + screenPosition.y <= flowRect.bottom; + + // Create a new node and add it to the flow + if (isInFlow) { + const position = screenToFlowPosition(screenPosition); + + const newNode = { + id: getId(), + type: nodeType, + position, + data: { label: `${nodeType} node` }, + }; + + setNodes((nds) => nds.concat(newNode)); + } + }, + [setNodes, screenToFlowPosition], + ); + + return ( + + ); +} \ No newline at end of file -- 2.49.1 From affdc0c3cdd7e35be5c99d5d9c5756c063f5583d Mon Sep 17 00:00:00 2001 From: JGerla Date: Wed, 1 Oct 2025 13:45:20 +0200 Subject: [PATCH 014/184] feat: modified DnD sidebar to provide different node types Modified the drag and drop sidebar to create a node of the correct type instead of creating only default nodes, regardless of specified node types for a respective option. ref: N25B-114 --- .../components/DragDropSidebar.tsx | 41 +++++++++++++++---- 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/src/visualProgrammingUI/components/DragDropSidebar.tsx b/src/visualProgrammingUI/components/DragDropSidebar.tsx index 9c1e3dd..219e3a2 100644 --- a/src/visualProgrammingUI/components/DragDropSidebar.tsx +++ b/src/visualProgrammingUI/components/DragDropSidebar.tsx @@ -72,14 +72,41 @@ export function Sidebar() { if (isInFlow) { const position = screenToFlowPosition(screenPosition); - const newNode = { - id: getId(), - type: nodeType, - position, - data: { label: `${nodeType} node` }, - }; + const newNode = () => { + switch (nodeType) { + case "phase": + return { + id: getId(), + type: nodeType, + position, + data: {label: `"new"`, number: (-1)}, + }; + case "start": + return { + id: getId(), + type: nodeType, + position, + data: {label: `new start node`}, + }; + case "end": + return { + id: getId(), + type: nodeType, + position, + data: {label: `new end node`}, + }; + default: { + return { + id: getId(), + type: nodeType, + position, + data: {label: `new default node`}, + }; + } + } + } - setNodes((nds) => nds.concat(newNode)); + setNodes((nds) => nds.concat(newNode())); } }, [setNodes, screenToFlowPosition], -- 2.49.1 From 96053e798ad07f0eaa6415de75f41bbfee453f09 Mon Sep 17 00:00:00 2001 From: JobvAlewijk Date: Wed, 1 Oct 2025 14:06:30 +0200 Subject: [PATCH 015/184] fix: moved ui2cb communication into server --- src/App.tsx | 68 ++--------------------- src/pages/Home/Home.tsx | 4 +- src/pages/ServerComms/ServerComms.css | 0 src/pages/ServerComms/ServerComms.tsx | 80 +++++++++++++++++++++++++++ src/pages/TemplatePage/Template.tsx | 3 +- 5 files changed, 88 insertions(+), 67 deletions(-) create mode 100644 src/pages/ServerComms/ServerComms.css create mode 100644 src/pages/ServerComms/ServerComms.tsx diff --git a/src/App.tsx b/src/App.tsx index 8c9d79d..757e769 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,78 +1,18 @@ -import { useState, useEffect } from 'react' import { Routes, Route } from 'react-router' import './App.css' import TemplatePage from './pages/TemplatePage/Template.tsx' import Home from './pages/Home/Home.tsx' +import ServerComms from './pages/ServerComms/ServerComms.tsx' -function App() { - const [message, setMessage] = useState(''); - const [sseMessage, setSseMessage] = useState(''); - const [spoken, setSpoken] = useState(""); - - const sendMessage = async () => { - try { - const response = await fetch("http://localhost:8000/message", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ message }), - }); - const data = await response.json(); - console.log(data); - } catch (error) { - console.error("Error sending message: ", error); - } - }; - - useEffect(() => { - const eventSource = new EventSource("http://localhost:8000/sse"); - - eventSource.onmessage = (event) => { - setSseMessage(event.data); - - try { - const data = JSON.parse(event.data); - if (data.speech) setSpoken(data.speech); - } catch {} - }; - - return () => { - eventSource.close(); - }; - }, []); - - return ( -
-
- setMessage(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && sendMessage().then(() => setMessage(""))} - placeholder="Enter a message" - /> - -
-
-

Message from Server (SSE):

-

{sseMessage}

-
-
-

Spoken text (SSE):

-

{spoken}

-
-
- ); -/* - function App(){ +function App(){ return ( } /> } /> + } /> ) } -*/ + export default App diff --git a/src/pages/Home/Home.tsx b/src/pages/Home/Home.tsx index 5c4a72d..8767db3 100644 --- a/src/pages/Home/Home.tsx +++ b/src/pages/Home/Home.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +//import { useState } from 'react' import { Link } from 'react-router' import reactLogo from '../../assets/react.svg' import viteLogo from '../../assets/vite.svg' @@ -29,7 +29,7 @@ function Home() {

Vite + React

- + diff --git a/src/pages/ServerComms/ServerComms.css b/src/pages/ServerComms/ServerComms.css new file mode 100644 index 0000000..e69de29 diff --git a/src/pages/ServerComms/ServerComms.tsx b/src/pages/ServerComms/ServerComms.tsx new file mode 100644 index 0000000..c16ec81 --- /dev/null +++ b/src/pages/ServerComms/ServerComms.tsx @@ -0,0 +1,80 @@ +import { useState, useEffect } from 'react' +import { Link } from 'react-router' +//import Counter from '../../components/components.tsx' + + +//this is your css file where you can style your buttons and such +//you can still use css parts from App.css, but also overwrite them + +function ServerComms() { + const [message, setMessage] = useState(''); + const [sseMessage, setSseMessage] = useState(''); + const [spoken, setSpoken] = useState(""); + + const sendMessage = async () => { + try { + const response = await fetch("http://localhost:8000/message", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ message }), + }); + const data = await response.json(); + console.log(data); + } catch (error) { + console.error("Error sending message: ", error); + } + }; + + useEffect(() => { + const eventSource = new EventSource("http://localhost:8000/sse"); + + eventSource.onmessage = (event) => { + setSseMessage(event.data); + + try { + const data = JSON.parse(event.data); + if (data.speech) setSpoken(data.speech); + } catch {} + }; + + return () => { + eventSource.close(); + }; + }, []); + + return ( +
+
+ setMessage(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && sendMessage().then(() => setMessage(""))} + placeholder="Enter a message" + /> + +
+
+

Message from Server (SSE):

+

{sseMessage}

+
+
+

Spoken text (SSE):

+

{spoken}

+
+
+ {/* here you link to the homepage, in App.tsx you can link new pages */} + + +
+
+ + + ); +} + +export default ServerComms \ No newline at end of file diff --git a/src/pages/TemplatePage/Template.tsx b/src/pages/TemplatePage/Template.tsx index 4cb3118..1a3feac 100644 --- a/src/pages/TemplatePage/Template.tsx +++ b/src/pages/TemplatePage/Template.tsx @@ -1,7 +1,8 @@ import { useState } from 'react' import { Link } from 'react-router' import Counter from '../../components/components.tsx' -import style from './Template.module.css' +//import style from './Template.module.css' +import '../../App.css' //this is your css file where you can style your buttons and such //you can still use css parts from App.css, but also overwrite them -- 2.49.1 From b1b1c83d73a35f9531e67d5166c6a9bdd834c7da Mon Sep 17 00:00:00 2001 From: JGerla Date: Wed, 1 Oct 2025 14:22:31 +0200 Subject: [PATCH 016/184] feat: added VisProgUI to a page ref: N25B-114 --- src/App.tsx | 5 +++-- src/pages/Home/Home.tsx | 3 +-- src/pages/TemplatePage/Template.tsx | 3 +-- src/pages/VisProgPage/VisProg.module.css | 4 ++++ src/pages/VisProgPage/VisProg.tsx | 23 +++++++++++++++++++++++ 5 files changed, 32 insertions(+), 6 deletions(-) create mode 100644 src/pages/VisProgPage/VisProg.module.css create mode 100644 src/pages/VisProgPage/VisProg.tsx diff --git a/src/App.tsx b/src/App.tsx index fe7cb61..d8d5cb2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,14 +1,15 @@ import { Routes, Route } from 'react-router' import './App.css' -import TemplatePage from './pages/TemplatePage/Template.tsx' +import VisProg from './pages/VisProgPage/VisProg.tsx' import Home from './pages/Home/Home.tsx' + function App(){ return ( } /> - } /> + } /> ) } diff --git a/src/pages/Home/Home.tsx b/src/pages/Home/Home.tsx index 5c4a72d..956506c 100644 --- a/src/pages/Home/Home.tsx +++ b/src/pages/Home/Home.tsx @@ -1,4 +1,3 @@ -import { useState } from 'react' import { Link } from 'react-router' import reactLogo from '../../assets/react.svg' import viteLogo from '../../assets/vite.svg' @@ -29,7 +28,7 @@ function Home() {

Vite + React

- + diff --git a/src/pages/TemplatePage/Template.tsx b/src/pages/TemplatePage/Template.tsx index 4cb3118..0f08c25 100644 --- a/src/pages/TemplatePage/Template.tsx +++ b/src/pages/TemplatePage/Template.tsx @@ -1,7 +1,6 @@ -import { useState } from 'react' + import { Link } from 'react-router' import Counter from '../../components/components.tsx' -import style from './Template.module.css' //this is your css file where you can style your buttons and such //you can still use css parts from App.css, but also overwrite them diff --git a/src/pages/VisProgPage/VisProg.module.css b/src/pages/VisProgPage/VisProg.module.css new file mode 100644 index 0000000..8526661 --- /dev/null +++ b/src/pages/VisProgPage/VisProg.module.css @@ -0,0 +1,4 @@ +button.reset:hover { + background-color: yellow; +} + diff --git a/src/pages/VisProgPage/VisProg.tsx b/src/pages/VisProgPage/VisProg.tsx new file mode 100644 index 0000000..953e3f8 --- /dev/null +++ b/src/pages/VisProgPage/VisProg.tsx @@ -0,0 +1,23 @@ +import { Link } from 'react-router' +import VisProgUI from "../../visualProgrammingUI/VisProgUI.tsx"; + + +//this is your css file where you can style your buttons and such +//you can still use css parts from App.css, but also overwrite them + +function VisProgPage() { + + + return ( + <> + + {/* here you link to the homepage, in App.tsx you can link new pages */} + + + + ) +} + +export default VisProgPage \ No newline at end of file -- 2.49.1 From f9f5a71c474466ff2e8f4ce7972105a4fede31ab Mon Sep 17 00:00:00 2001 From: JGerla Date: Wed, 1 Oct 2025 15:51:24 +0200 Subject: [PATCH 017/184] feat: added delete option to Nodes added a delete option to nodes, nodes now have strongly typed nodeProps to conform to typescript type safety norms. ref: N25B-114 --- .../components/NodeDefinitions.tsx | 46 +++++++++++++++++-- 1 file changed, 42 insertions(+), 4 deletions(-) diff --git a/src/visualProgrammingUI/components/NodeDefinitions.tsx b/src/visualProgrammingUI/components/NodeDefinitions.tsx index 99ad1da..7c712b3 100644 --- a/src/visualProgrammingUI/components/NodeDefinitions.tsx +++ b/src/visualProgrammingUI/components/NodeDefinitions.tsx @@ -1,4 +1,4 @@ -import { Handle, Position } from '@xyflow/react'; +import {Handle, NodeToolbar, Position, useReactFlow} from '@xyflow/react'; import '@xyflow/react/dist/style.css'; import '../VisProgUI.css'; @@ -16,12 +16,37 @@ type phaseNodeData = defaultNodeData & { export type nodeData = defaultNodeData | startNodeData | phaseNodeData | endNodeData; +// Node Toolbar definition + +type ToolbarProps= { + nodeId: string; +}; + +export function Toolbar({nodeId}:ToolbarProps) { + const { setNodes, setEdges } = useReactFlow(); + + const handleDelete = () => { + setNodes((nds) => nds.filter((n) => n.id !== nodeId)); + setEdges((eds) => eds.filter((e) => e.source !== nodeId && e.target !== nodeId)); + }; + return ( + + + ); +} + + // Definitions of Nodes +type StartNodeProps = { + id: string; + data: startNodeData; +}; -export const StartNode= ({ data } : {data : startNodeData}) => { +export const StartNode= ({ id, data }: StartNodeProps) => { return ( <> +
data test {data.label}
@@ -30,10 +55,15 @@ export const StartNode= ({ data } : {data : startNodeData}) => { ); }; +type EndNodeProps = { + id: string; + data: endNodeData; +}; -export const EndNode= ({ data } : {data : endNodeData}) => { +export const EndNode= ({ id, data }: EndNodeProps) => { return ( <> +
{data.label}
@@ -42,9 +72,16 @@ export const EndNode= ({ data } : {data : endNodeData}) => { ); }; -export const PhaseNode= ({ data } : {data : phaseNodeData}) => { + +type PhaseNodeProps = { + id: string; + data: phaseNodeData; +}; + +export const PhaseNode= ({ id, data }: PhaseNodeProps) => { return ( <> +
phase {data.number} {data.label}
@@ -53,3 +90,4 @@ export const PhaseNode= ({ data } : {data : phaseNodeData}) => { ); }; + -- 2.49.1 From 6c22286b28d64d87c1c6f5bdd2d7efbf48862cbf Mon Sep 17 00:00:00 2001 From: JGerla Date: Wed, 1 Oct 2025 15:51:24 +0200 Subject: [PATCH 018/184] feat: added delete option to Nodes added a delete option to nodes, nodes now have strongly typed nodeProps to conform to typescript type safety norms. ref: N25B-114 --- .../components/NodeDefinitions.tsx | 48 +++++++++++++++++-- 1 file changed, 44 insertions(+), 4 deletions(-) diff --git a/src/visualProgrammingUI/components/NodeDefinitions.tsx b/src/visualProgrammingUI/components/NodeDefinitions.tsx index 99ad1da..7463e84 100644 --- a/src/visualProgrammingUI/components/NodeDefinitions.tsx +++ b/src/visualProgrammingUI/components/NodeDefinitions.tsx @@ -1,7 +1,8 @@ -import { Handle, Position } from '@xyflow/react'; +import {Handle, NodeToolbar, Position, useReactFlow} from '@xyflow/react'; import '@xyflow/react/dist/style.css'; import '../VisProgUI.css'; + // Datatypes for NodeTypes type defaultNodeData = { @@ -16,12 +17,38 @@ type phaseNodeData = defaultNodeData & { export type nodeData = defaultNodeData | startNodeData | phaseNodeData | endNodeData; + +// Node Toolbar definition + +type ToolbarProps= { + nodeId: string; +}; + +export function Toolbar({nodeId}:ToolbarProps) { + const { setNodes, setEdges } = useReactFlow(); + + const handleDelete = () => { + setNodes((nds) => nds.filter((n) => n.id !== nodeId)); + setEdges((eds) => eds.filter((e) => e.source !== nodeId && e.target !== nodeId)); + }; + return ( + + + ); +} + + // Definitions of Nodes +type StartNodeProps = { + id: string; + data: startNodeData; +}; -export const StartNode= ({ data } : {data : startNodeData}) => { +export const StartNode= ({ id, data }: StartNodeProps) => { return ( <> +
data test {data.label}
@@ -30,10 +57,15 @@ export const StartNode= ({ data } : {data : startNodeData}) => { ); }; +type EndNodeProps = { + id: string; + data: endNodeData; +}; -export const EndNode= ({ data } : {data : endNodeData}) => { +export const EndNode= ({ id, data }: EndNodeProps) => { return ( <> +
{data.label}
@@ -42,9 +74,16 @@ export const EndNode= ({ data } : {data : endNodeData}) => { ); }; -export const PhaseNode= ({ data } : {data : phaseNodeData}) => { + +type PhaseNodeProps = { + id: string; + data: phaseNodeData; +}; + +export const PhaseNode= ({ id, data }: PhaseNodeProps) => { return ( <> +
phase {data.number} {data.label}
@@ -53,3 +92,4 @@ export const PhaseNode= ({ data } : {data : phaseNodeData}) => { ); }; + -- 2.49.1 From 77808784473e07a483e9a6b146ad0ec0ace65a42 Mon Sep 17 00:00:00 2001 From: JGerla Date: Wed, 1 Oct 2025 17:13:55 +0200 Subject: [PATCH 019/184] feat: added Norm nodes and an extra handle on phase nodes ref: N25B-114 --- src/visualProgrammingUI/VisProgUI.tsx | 6 +++--- .../components/DragDropSidebar.tsx | 10 ++++++++++ .../components/NodeDefinitions.tsx | 18 ++++++++++++++++++ 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/visualProgrammingUI/VisProgUI.tsx b/src/visualProgrammingUI/VisProgUI.tsx index 55c5a85..a2b1e9c 100644 --- a/src/visualProgrammingUI/VisProgUI.tsx +++ b/src/visualProgrammingUI/VisProgUI.tsx @@ -21,7 +21,8 @@ import '@xyflow/react/dist/style.css'; import { StartNode, EndNode, - PhaseNode + PhaseNode, + NormNode } from "./components/NodeDefinitions.tsx"; import { Sidebar } from './components/DragDropSidebar.tsx'; @@ -30,6 +31,7 @@ const nodeTypes = { start: StartNode, end: EndNode, phase: PhaseNode, + norm: NormNode }; const initialNodes = [ @@ -67,8 +69,6 @@ const VisProgUI = ()=> { const [nodes, , onNodesChange] = useNodesState(initialNodes); const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); - - const onConnect = useCallback( (params: Edge | Connection) => setEdges((els) => addEdge(params, els)), [setEdges], diff --git a/src/visualProgrammingUI/components/DragDropSidebar.tsx b/src/visualProgrammingUI/components/DragDropSidebar.tsx index 219e3a2..b3926d9 100644 --- a/src/visualProgrammingUI/components/DragDropSidebar.tsx +++ b/src/visualProgrammingUI/components/DragDropSidebar.tsx @@ -95,6 +95,13 @@ export function Sidebar() { position, data: {label: `new end node`}, }; + case "norm": + return { + id: getId(), + type: nodeType, + position, + data: {label: `new norm node`}, + }; default: { return { id: getId(), @@ -126,6 +133,9 @@ export function Sidebar() { phase Node + + norm Node + ); } \ No newline at end of file diff --git a/src/visualProgrammingUI/components/NodeDefinitions.tsx b/src/visualProgrammingUI/components/NodeDefinitions.tsx index 7c712b3..b4547b2 100644 --- a/src/visualProgrammingUI/components/NodeDefinitions.tsx +++ b/src/visualProgrammingUI/components/NodeDefinitions.tsx @@ -10,6 +10,7 @@ type defaultNodeData = { type startNodeData = defaultNodeData; type endNodeData = defaultNodeData; +type normNodeData = defaultNodeData; type phaseNodeData = defaultNodeData & { number: number; }; @@ -85,9 +86,26 @@ export const PhaseNode= ({ id, data }: PhaseNodeProps) => {
phase {data.number} {data.label}
+
); }; +type NormNodeProps = { + id: string; + data: normNodeData; +}; + +export const NormNode= ({ id, data }: NormNodeProps) => { + return ( + <> + +
+
Norm {data.label}
+ +
+ + ); +}; \ No newline at end of file -- 2.49.1 From 10522b71c368301a6b8949def72214dfbb6b7b4b Mon Sep 17 00:00:00 2001 From: Twirre Meulenbelt <43213592+TwirreM@users.noreply.github.com> Date: Wed, 1 Oct 2025 22:56:03 +0200 Subject: [PATCH 020/184] chore: combined some branches, improved style This demo branch contains code from multiple different branches. DO NOT MERGE this branch because it looks like I'm the author of all these changes. --- package-lock.json | 241 +++++++++++- package.json | 2 + src/App.css | 109 ++++-- src/App.tsx | 20 +- src/assets/data.ts | 361 ++++++++++++++++++ src/pages/Home/Home.module.css | 6 + src/pages/Home/Home.tsx | 45 +-- src/pages/Logging/Logging.module.css | 17 + src/pages/Logging/Logging.tsx | 78 ++++ src/pages/ServerComms/ServerComms.css | 0 src/pages/ServerComms/ServerComms.tsx | 86 +++-- src/pages/VisProgPage/VisProg.tsx | 11 + src/visualProgrammingUI/VisProgUI.css | 7 + src/visualProgrammingUI/VisProgUI.tsx | 132 +++++++ .../components/DragDropSidebar.tsx | 141 +++++++ .../components/NodeDefinitions.tsx | 111 ++++++ 16 files changed, 1251 insertions(+), 116 deletions(-) create mode 100644 src/assets/data.ts create mode 100644 src/pages/Logging/Logging.module.css create mode 100644 src/pages/Logging/Logging.tsx delete mode 100644 src/pages/ServerComms/ServerComms.css create mode 100644 src/pages/VisProgPage/VisProg.tsx create mode 100644 src/visualProgrammingUI/VisProgUI.css create mode 100644 src/visualProgrammingUI/VisProgUI.tsx create mode 100644 src/visualProgrammingUI/components/DragDropSidebar.tsx create mode 100644 src/visualProgrammingUI/components/NodeDefinitions.tsx diff --git a/package-lock.json b/package-lock.json index 0b92f5c..db54d49 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,8 @@ "name": "pepperplus-ui", "version": "0.0.0", "dependencies": { + "@neodrag/react": "^2.3.1", + "@xyflow/react": "^12.8.6", "react": "^19.1.1", "react-dom": "^19.1.1", "react-router": "^7.9.3" @@ -1006,6 +1008,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@neodrag/react": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@neodrag/react/-/react-2.3.1.tgz", + "integrity": "sha512-mOVefo3mFmaVLs9PB5F5wMXnnclG81qjOaPHyf8YZUnw/Ciz0pAqyJDwDJk0nPTIK5I2x1JdjXSchGNdCxZNRQ==", + "license": "MIT" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1404,6 +1412,55 @@ "@babel/types": "^7.28.2" } }, + "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-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "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-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1422,7 +1479,7 @@ "version": "19.1.13", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.13.tgz", "integrity": "sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.0.2" @@ -1730,6 +1787,38 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@xyflow/react": { + "version": "12.8.6", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.8.6.tgz", + "integrity": "sha512-SksAm2m4ySupjChphMmzvm55djtgMDPr+eovPDdTnyGvShf73cvydfoBfWDFllooIQ4IaiUL5yfxHRwU0c37EA==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.70", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.70", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.70.tgz", + "integrity": "sha512-PpC//u9zxdjj0tfTSmZrg3+sRbTz6kop/Amky44U2Dl51sxzDTIUfXMwETOYpmr2dqICWXBIJwXL2a9QWtX2XA==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -1916,6 +2005,12 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -1978,9 +2073,114 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true, + "devOptional": true, "license": "MIT" }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -3287,6 +3487,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", + "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vite": { "version": "7.1.7", "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.7.tgz", @@ -3438,6 +3647,34 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "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 + } + } } } } diff --git a/package.json b/package.json index 6070bd8..252cf4f 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,8 @@ "preview": "vite preview" }, "dependencies": { + "@neodrag/react": "^2.3.1", + "@xyflow/react": "^12.8.6", "react": "^19.1.1", "react-dom": "^19.1.1", "react-router": "^7.9.3" diff --git a/src/App.css b/src/App.css index dcb46cf..0c641e7 100644 --- a/src/App.css +++ b/src/App.css @@ -5,18 +5,6 @@ text-align: center; } -.logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: filter 300ms; -} -.logo:hover { - filter: drop-shadow(0 0 2em #646cffaa); -} -.logo.react:hover { - filter: drop-shadow(0 0 2em #61dafbaa); -} .logopepper { @@ -32,27 +20,21 @@ filter: drop-shadow(0 0 10em #4eff14aa); } -@keyframes logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - - @keyframes logo-pepper-spin { - from { - transform: rotate(-20deg); + 0% { + transform: rotate(0); } - to { + 25% { transform: rotate(20deg); } + 75% { + transform: rotate(-20deg); + } + 100% { + transform: rotate(0); + } } - - @keyframes logo-pepper-scale { from { transform: scale(1,1); @@ -63,19 +45,13 @@ } @media (prefers-reduced-motion: no-preference) { - a:nth-of-type(2) .logo { - animation: logo-spin infinite 20s linear; + .logopepper:hover { + animation: logo-pepper-spin infinite 1s linear; } } @media (prefers-reduced-motion: no-preference) { - .logopepper { - animation: logo-pepper-spin infinite 1s linear alternate; - } -} - -@media (prefers-reduced-motion: no-preference) { - .logoPepperScaling { + .logoPepperScaling:hover { animation: logo-pepper-scale infinite 1s linear alternate; } } @@ -113,3 +89,64 @@ button.movePage.right{ button.movePage:hover{ background-color: rgb(0, 176, 176); } + + + + + +.flex-row { + display: flex; + flex-direction: row; +} +.flex-col { + display: flex; + flex-direction: column; +} + +.flex-1 { + flex: 1; +} + +.flex-wrap { + flex-wrap: wrap; +} + +.align-center { + align-items: center; +} +.justify-center { + justify-content: center; +} +.justify-between { + justify-content: space-between; +} + +.gap-sm { + gap: .25rem; +} +.gap-md { + gap: .5rem; +} +.gap-lg { + gap: 1rem; +} + +.padding-sm { + padding: .25rem; +} +.padding-md { + padding: .5rem; +} +.padding-lg { + padding: 1rem; +} + +.round-sm { + border-radius: .25rem; +} +.round-md { + border-radius: .5rem; +} +.round-lg { + border-radius: 1rem; +} \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index 757e769..0833bdb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,17 +1,25 @@ -import { Routes, Route } from 'react-router' +import { Routes, Route, Link } from 'react-router' import './App.css' import TemplatePage from './pages/TemplatePage/Template.tsx' import Home from './pages/Home/Home.tsx' import ServerComms from './pages/ServerComms/ServerComms.tsx' +import Logging from './pages/Logging/Logging.tsx' +import VisProg from "./pages/VisProgPage/VisProg.tsx"; function App(){ return ( - - } /> - } /> - } /> - +
+ {/* Should not use inline styles like this */} + Home + + } /> + } /> + } /> + } /> + } /> + +
) } diff --git a/src/assets/data.ts b/src/assets/data.ts new file mode 100644 index 0000000..c1eacfb --- /dev/null +++ b/src/assets/data.ts @@ -0,0 +1,361 @@ +export const DATA: LogEntry[] = [ + { + id: "1", + timestamp: "2025-10-01T12:00:00Z", + level: "info", + msg: "User said: Hello, Pepper!", + type: "speech", + }, + { + id: "2", + timestamp: "2025-10-01T12:00:05Z", + level: "debug", + msg: "Proximity sensor value: 0.85", + type: "sensor", + }, + { + id: "3", + timestamp: "2025-10-01T12:00:10Z", + level: "warn", + msg: "Battery level low: 15%", + type: "system", + }, + { + id: "4", + timestamp: "2025-10-01T12:00:15Z", + level: "info", + msg: "User requested weather update.", + type: "speech", + }, + { + id: "5", + timestamp: "2025-10-01T12:00:20Z", + level: "debug", + msg: "Microphone activated.", + type: "system", + }, + { + id: "6", + timestamp: "2025-10-01T12:00:25Z", + level: "warn", + msg: "Obstacle detected in front.", + type: "sensor", + }, + { + id: "7", + timestamp: "2025-10-01T12:00:30Z", + level: "info", + msg: "User said: Thank you!", + type: "speech", + }, + { + id: "8", + timestamp: "2025-10-01T12:00:35Z", + level: "debug", + msg: "Network latency: 120ms", + type: "system", + }, + { + id: "9", + timestamp: "2025-10-01T12:00:40Z", + level: "warn", + msg: "High CPU usage detected.", + type: "system", + }, + { + id: "10", + timestamp: "2025-10-01T12:00:45Z", + level: "info", + msg: "User started a new session.", + type: "system", + }, + { + id: "11", + timestamp: "2025-10-01T12:01:00Z", + level: "info", + msg: "User asked: What's the weather?", + type: "speech", + }, + { + id: "12", + timestamp: "2025-10-01T12:01:05Z", + level: "debug", + msg: "Camera initialized.", + type: "system", + }, + { + id: "13", + timestamp: "2025-10-01T12:01:10Z", + level: "warn", + msg: "Temperature sensor disconnected.", + type: "sensor", + }, + { + id: "14", + timestamp: "2025-10-01T12:01:15Z", + level: "info", + msg: "User said: Play some music.", + type: "speech", + }, + { + id: "15", + timestamp: "2025-10-01T12:01:20Z", + level: "debug", + msg: "Audio output device selected: Speaker.", + type: "system", + }, + { + id: "16", + timestamp: "2025-10-01T12:01:25Z", + level: "warn", + msg: "Low light detected in room.", + type: "sensor", + }, + { + id: "17", + timestamp: "2025-10-01T12:01:30Z", + level: "info", + msg: "User said: Turn on the lights.", + type: "speech", + }, + { + id: "18", + timestamp: "2025-10-01T12:01:35Z", + level: "debug", + msg: "Light control signal sent.", + type: "system", + }, + { + id: "19", + timestamp: "2025-10-01T12:01:40Z", + level: "warn", + msg: "Light bulb not responding.", + type: "system", + }, + { + id: "20", + timestamp: "2025-10-01T12:01:45Z", + level: "info", + msg: "User said: Good night.", + type: "speech", + }, + { + id: "21", + timestamp: "2025-10-01T12:02:00Z", + level: "info", + msg: "User asked: What's the time?", + type: "speech", + }, + { + id: "22", + timestamp: "2025-10-01T12:02:05Z", + level: "debug", + msg: "Time module loaded.", + type: "system", + }, + { + id: "23", + timestamp: "2025-10-01T12:02:10Z", + level: "warn", + msg: "WiFi signal weak.", + type: "system", + }, + { + id: "24", + timestamp: "2025-10-01T12:02:15Z", + level: "info", + msg: "User said: Set an alarm for 7 AM.", + type: "speech", + }, + { + id: "25", + timestamp: "2025-10-01T12:02:20Z", + level: "debug", + msg: "Alarm scheduled for 7:00 AM.", + type: "system", + }, + { + id: "26", + timestamp: "2025-10-01T12:02:25Z", + level: "warn", + msg: "Alarm module not responding.", + type: "system", + }, + { + id: "27", + timestamp: "2025-10-01T12:02:30Z", + level: "info", + msg: "User said: Cancel the alarm.", + type: "speech", + }, + { + id: "28", + timestamp: "2025-10-01T12:02:35Z", + level: "debug", + msg: "Alarm cancellation requested.", + type: "system", + }, + { + id: "29", + timestamp: "2025-10-01T12:02:40Z", + level: "warn", + msg: "Alarm cancellation failed.", + type: "system", + }, + { + id: "30", + timestamp: "2025-10-01T12:02:45Z", + level: "info", + msg: "User said: Open the window.", + type: "speech", + }, + { + id: "31", + timestamp: "2025-10-01T12:03:00Z", + level: "info", + msg: "User asked: What's on my calendar?", + type: "speech", + }, + { + id: "32", + timestamp: "2025-10-01T12:03:05Z", + level: "debug", + msg: "Calendar module loaded.", + type: "system", + }, + { + id: "33", + timestamp: "2025-10-01T12:03:10Z", + level: "warn", + msg: "Calendar sync failed.", + type: "system", + }, + { + id: "34", + timestamp: "2025-10-01T12:03:15Z", + level: "info", + msg: "User said: Remind me to call John.", + type: "speech", + }, + { + id: "35", + timestamp: "2025-10-01T12:03:20Z", + level: "debug", + msg: "Reminder set for John.", + type: "system", + }, + { + id: "36", + timestamp: "2025-10-01T12:03:25Z", + level: "warn", + msg: "Reminder module not available.", + type: "system", + }, + { + id: "37", + timestamp: "2025-10-01T12:03:30Z", + level: "info", + msg: "User said: What's the news?", + type: "speech", + }, + { + id: "38", + timestamp: "2025-10-01T12:03:35Z", + level: "debug", + msg: "News API request sent.", + type: "system", + }, + { + id: "39", + timestamp: "2025-10-01T12:03:40Z", + level: "warn", + msg: "News API rate limit reached.", + type: "system", + }, + { + id: "40", + timestamp: "2025-10-01T12:03:45Z", + level: "info", + msg: "User said: Tell me a joke.", + type: "speech", + }, + { + id: "41", + timestamp: "2025-10-01T12:04:00Z", + level: "info", + msg: "User asked: What's the temperature?", + type: "speech", + }, + { + id: "42", + timestamp: "2025-10-01T12:04:05Z", + level: "debug", + msg: "Temperature sensor reading: 22°C.", + type: "sensor", + }, + { + id: "43", + timestamp: "2025-10-01T12:04:10Z", + level: "warn", + msg: "Temperature sensor calibration needed.", + type: "sensor", + }, + { + id: "44", + timestamp: "2025-10-01T12:04:15Z", + level: "info", + msg: "User said: Start cleaning.", + type: "speech", + }, + { + id: "45", + timestamp: "2025-10-01T12:04:20Z", + level: "debug", + msg: "Vacuum motor started.", + type: "system", + }, + { + id: "46", + timestamp: "2025-10-01T12:04:25Z", + level: "warn", + msg: "Vacuum bin full.", + type: "system", + }, + { + id: "47", + timestamp: "2025-10-01T12:04:30Z", + level: "info", + msg: "User said: Stop cleaning.", + type: "speech", + }, + { + id: "48", + timestamp: "2025-10-01T12:04:35Z", + level: "debug", + msg: "Vacuum motor stopped.", + type: "system", + }, + { + id: "49", + timestamp: "2025-10-01T12:04:40Z", + level: "warn", + msg: "Obstacle detected during cleaning.", + type: "sensor", + }, + { + id: "50", + timestamp: "2025-10-01T12:04:45Z", + level: "info", + msg: "User said: Goodbye!", + type: "speech", + }, +]; + +interface LogEntry { + id: string; + type?: string; + timestamp: string; + level: "info" | "debug" | "warn"; + msg?: string; +} + diff --git a/src/pages/Home/Home.module.css b/src/pages/Home/Home.module.css index aed0f27..d609fe9 100644 --- a/src/pages/Home/Home.module.css +++ b/src/pages/Home/Home.module.css @@ -4,3 +4,9 @@ .card { padding: 2em; } + +.links { + display: flex; + flex-direction: column; + gap: 1em; +} \ No newline at end of file diff --git a/src/pages/Home/Home.tsx b/src/pages/Home/Home.tsx index 8767db3..582357b 100644 --- a/src/pages/Home/Home.tsx +++ b/src/pages/Home/Home.tsx @@ -1,47 +1,20 @@ -//import { useState } from 'react' import { Link } from 'react-router' -import reactLogo from '../../assets/react.svg' -import viteLogo from '../../assets/vite.svg' import pepperLogo from '../../assets/pepper_transp2_small.svg' -import style from './Home.module.css' -import Counter from '../../components/components.tsx' +import styles from './Home.module.css' function Home() { - - - return ( <> -
-
- - Pepper logo - -
- - - Vite logo - - - React logo + -

Vite + React

- - - - - - -

- Edit src/App.tsx and save to test HMR -

- -

- Click on the Vite and React logos to learn more -

+
+ Robot interaction → + Node editor → + Logs → +
) } diff --git a/src/pages/Logging/Logging.module.css b/src/pages/Logging/Logging.module.css new file mode 100644 index 0000000..52191c3 --- /dev/null +++ b/src/pages/Logging/Logging.module.css @@ -0,0 +1,17 @@ +.DivToScroll{ + background-color: color-mix(in srgb, canvas, #000 5%); + border: 1px solid color-mix(in srgb, canvas, #000 15%); + border-radius: 4px 0 4px 0; + color: #3B3C3E; + font-size: 12px; + font-weight: bold; + left: -1px; + padding: 10px 7px 5px; +} + +.DivWithScroll{ + height:50vh; + width:100vh; + overflow:scroll; + overflow-x:hidden; +} diff --git a/src/pages/Logging/Logging.tsx b/src/pages/Logging/Logging.tsx new file mode 100644 index 0000000..31945c1 --- /dev/null +++ b/src/pages/Logging/Logging.tsx @@ -0,0 +1,78 @@ +import { useState } from 'react'; +import { DATA } from "../../assets/data"; +import styles from './Logging.module.css'; + + +// const dataType = DATA as { id: string; level: "debug"|"info"|"warn"|"error"; msg: string; timestamp?: string }; +type Level = "debug" | "info" | "warn" | "error"; + +// make optional fields optional +type LogEntry = { + id: string; + level: Level; + timestamp?: string; + msg?: string; + type?: "speech" | "sensor" | "system" | string; + +}; + +function getLevelColor(level: Level) { + switch (level) { + case "debug": + return "gray"; + case "info": + return "blue"; + case "warn": + return "red"; + case "error": + return "red"; + default: + return "black"; + } +} + +function Logging() { + const [logs, setLogs] = useState([]); + + const logDiv = ( +
+
+ {logs.map((log) => ( +
+ + [{log.timestamp}] + + + {log.msg ? log.msg : "No message"} + + + ({log.level}) + +
+ ))} +
+
+ ) + return ( + <> +

Log Screen

+ { logDiv } +
+ + +
+ + ) +} + +export default Logging diff --git a/src/pages/ServerComms/ServerComms.css b/src/pages/ServerComms/ServerComms.css deleted file mode 100644 index e69de29..0000000 diff --git a/src/pages/ServerComms/ServerComms.tsx b/src/pages/ServerComms/ServerComms.tsx index c16ec81..6d15524 100644 --- a/src/pages/ServerComms/ServerComms.tsx +++ b/src/pages/ServerComms/ServerComms.tsx @@ -1,15 +1,12 @@ -import { useState, useEffect } from 'react' -import { Link } from 'react-router' -//import Counter from '../../components/components.tsx' +import { useState, useEffect, useRef } from 'react' - -//this is your css file where you can style your buttons and such -//you can still use css parts from App.css, but also overwrite them - -function ServerComms() { +export default function ServerComms() { const [message, setMessage] = useState(''); - const [sseMessage, setSseMessage] = useState(''); - const [spoken, setSpoken] = useState(""); + + const [listening, setListening] = useState(false); + const [conversation, setConversation] = useState<{"role": "user" | "assistant", "content": string}[]>([]) + const conversationRef = useRef(null); + const [conversationIndex, setConversationIndex] = useState(0); const sendMessage = async () => { try { @@ -31,22 +28,31 @@ function ServerComms() { const eventSource = new EventSource("http://localhost:8000/sse"); eventSource.onmessage = (event) => { - setSseMessage(event.data); - try { const data = JSON.parse(event.data); - if (data.speech) setSpoken(data.speech); - } catch {} + if ("voice_active" in data) setListening(data.voice_active); + if ("speech" in data) setConversation(conversation => [...conversation, {"role": "user", "content": data.speech}]); + if ("llm_response" in data) setConversation(conversation => [...conversation, {"role": "assistant", "content": data.llm_response}]); + } catch { + console.log("Unparsable SSE message:", event.data); + } }; return () => { eventSource.close(); }; - }, []); + }, [conversationIndex]); + + useEffect(() => { + if (!conversationRef || !conversationRef.current) return; + conversationRef.current.scrollTop = conversationRef.current.scrollHeight; + }, [conversation]); return ( -
-
+
+

Robot interaction

+

Force robot speech

+
e.key === "Enter" && sendMessage().then(() => setMessage(""))} placeholder="Enter a message" /> - +
-
-

Message from Server (SSE):

-

{sseMessage}

-
-
-

Spoken text (SSE):

-

{spoken}

-
-
- {/* here you link to the homepage, in App.tsx you can link new pages */} - - +
+

Conversation

+

Listening {listening ? "🟢" : "🔴"}

+
+ {conversation.map((item) => ( +

{item["content"]}

+ ))} +
+
+ + +
- - ); } - -export default ServerComms \ No newline at end of file diff --git a/src/pages/VisProgPage/VisProg.tsx b/src/pages/VisProgPage/VisProg.tsx new file mode 100644 index 0000000..ec0055a --- /dev/null +++ b/src/pages/VisProgPage/VisProg.tsx @@ -0,0 +1,11 @@ +import VisProgUI from "../../visualProgrammingUI/VisProgUI.tsx"; + +function VisProgPage() { + return ( + <> + + + ) +} + +export default VisProgPage \ No newline at end of file diff --git a/src/visualProgrammingUI/VisProgUI.css b/src/visualProgrammingUI/VisProgUI.css new file mode 100644 index 0000000..8d82d09 --- /dev/null +++ b/src/visualProgrammingUI/VisProgUI.css @@ -0,0 +1,7 @@ +.default-node { + padding: 10px 20px; + background-color: canvas; + outline-style: solid; + border-radius: 5pt; + outline-width: 1pt; +} \ No newline at end of file diff --git a/src/visualProgrammingUI/VisProgUI.tsx b/src/visualProgrammingUI/VisProgUI.tsx new file mode 100644 index 0000000..a2b1e9c --- /dev/null +++ b/src/visualProgrammingUI/VisProgUI.tsx @@ -0,0 +1,132 @@ +import './VisProgUI.css' + +import { + useCallback, + useRef +} from 'react'; +import { + Background, + Controls, + ReactFlow, + ReactFlowProvider, + useNodesState, + useEdgesState, + reconnectEdge, + addEdge, + MarkerType, + type Edge, + type Connection, +} from '@xyflow/react'; +import '@xyflow/react/dist/style.css'; +import { + StartNode, + EndNode, + PhaseNode, + NormNode +} from "./components/NodeDefinitions.tsx"; + +import { Sidebar } from './components/DragDropSidebar.tsx'; + +const nodeTypes = { + start: StartNode, + end: EndNode, + phase: PhaseNode, + norm: NormNode +}; + +const initialNodes = [ + { + id: 'start', + type: 'start', + position: {x: 0, y: 0}, + data: {label: 'start'} + }, + { + id: 'genericPhase', + type: 'phase', + position: {x: 0, y: 150}, + data: {label: 'Generic Phase', number: 1}, + }, + { + id: 'end', + type: 'end', + position: {x: 0, y: 300}, + data: {label: 'End'} + } +]; +const initialEdges = [{id: 'start-end', source: 'start', target: 'end'}]; + +const defaultEdgeOptions = { + type: 'floating', + markerEnd: { + type: MarkerType.ArrowClosed, + color: '#505050', + }, +}; + +const VisProgUI = ()=> { + const edgeReconnectSuccessful = useRef(true); + const [nodes, , onNodesChange] = useNodesState(initialNodes); + const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); + + const onConnect = useCallback( + (params: Edge | Connection) => setEdges((els) => addEdge(params, els)), + [setEdges], + ); + + const onReconnectStart = useCallback(() => { + edgeReconnectSuccessful.current = false; + }, []); + + const onReconnect = useCallback((oldEdge: Edge, newConnection: Connection) => { + edgeReconnectSuccessful.current = true; + setEdges((els) => reconnectEdge(oldEdge, newConnection, els)); + }, [setEdges]); + + const onReconnectEnd = useCallback((_: unknown, edge: { id: string; }) => { + if (!edgeReconnectSuccessful.current) { + setEdges((eds) => eds.filter((e) => e.id !== edge.id)); + } + + edgeReconnectSuccessful.current = true; + }, [setEdges]); + + return ( +
+
+ + + + +
+
+ +
+
+ + ); +}; + +function VisualProgrammingUI(){ + return ( + + + + ); +} + +export default VisualProgrammingUI; \ No newline at end of file diff --git a/src/visualProgrammingUI/components/DragDropSidebar.tsx b/src/visualProgrammingUI/components/DragDropSidebar.tsx new file mode 100644 index 0000000..b3926d9 --- /dev/null +++ b/src/visualProgrammingUI/components/DragDropSidebar.tsx @@ -0,0 +1,141 @@ +import { useDraggable } from '@neodrag/react'; +import { + useReactFlow, + type XYPosition +} from '@xyflow/react'; +import { + type ReactNode, + useCallback, + useRef, + useState +} from 'react'; + + +// improve later to create better automatic IDs +let id = 0; +const getId = () => `dndnode_${id++}`; + + +interface DraggableNodeProps { + className?: string; + children: ReactNode; + nodeType: string; + onDrop: (nodeType: string, position: XYPosition) => void; +} + +function DraggableNode({ className, children, nodeType, onDrop }: DraggableNodeProps) { + const draggableRef = useRef(null); + const [position, setPosition] = useState({ x: 0, y: 0 }); + + + // @ts-ignore + useDraggable(draggableRef, { + position: position, + onDrag: ({ offsetX, offsetY }) => { + // Calculate position relative to the viewport + setPosition({ + x: offsetX, + y: offsetY, + }); + }, + onDragEnd: ({ event }) => { + setPosition({ x: 0, y: 0 }); + onDrop(nodeType, { + x: event.clientX, + y: event.clientY, + }); + }, + }); + + return ( +
+ {children} +
+ ); +} + +export function Sidebar() { + const { setNodes, screenToFlowPosition } = useReactFlow(); + + const handleNodeDrop = useCallback( + (nodeType: string, screenPosition: XYPosition) => { + const flow = document.querySelector('.react-flow'); + const flowRect = flow?.getBoundingClientRect(); + const isInFlow = + flowRect && + screenPosition.x >= flowRect.left && + screenPosition.x <= flowRect.right && + screenPosition.y >= flowRect.top && + screenPosition.y <= flowRect.bottom; + + // Create a new node and add it to the flow + if (isInFlow) { + const position = screenToFlowPosition(screenPosition); + + const newNode = () => { + switch (nodeType) { + case "phase": + return { + id: getId(), + type: nodeType, + position, + data: {label: `"new"`, number: (-1)}, + }; + case "start": + return { + id: getId(), + type: nodeType, + position, + data: {label: `new start node`}, + }; + case "end": + return { + id: getId(), + type: nodeType, + position, + data: {label: `new end node`}, + }; + case "norm": + return { + id: getId(), + type: nodeType, + position, + data: {label: `new norm node`}, + }; + default: { + return { + id: getId(), + type: nodeType, + position, + data: {label: `new default node`}, + }; + } + } + } + + setNodes((nds) => nds.concat(newNode())); + } + }, + [setNodes, screenToFlowPosition], + ); + + return ( + + ); +} \ No newline at end of file diff --git a/src/visualProgrammingUI/components/NodeDefinitions.tsx b/src/visualProgrammingUI/components/NodeDefinitions.tsx new file mode 100644 index 0000000..b4547b2 --- /dev/null +++ b/src/visualProgrammingUI/components/NodeDefinitions.tsx @@ -0,0 +1,111 @@ +import {Handle, NodeToolbar, Position, useReactFlow} from '@xyflow/react'; +import '@xyflow/react/dist/style.css'; +import '../VisProgUI.css'; + +// Datatypes for NodeTypes + +type defaultNodeData = { + label: string; +}; + +type startNodeData = defaultNodeData; +type endNodeData = defaultNodeData; +type normNodeData = defaultNodeData; +type phaseNodeData = defaultNodeData & { + number: number; +}; + +export type nodeData = defaultNodeData | startNodeData | phaseNodeData | endNodeData; + +// Node Toolbar definition + +type ToolbarProps= { + nodeId: string; +}; + +export function Toolbar({nodeId}:ToolbarProps) { + const { setNodes, setEdges } = useReactFlow(); + + const handleDelete = () => { + setNodes((nds) => nds.filter((n) => n.id !== nodeId)); + setEdges((eds) => eds.filter((e) => e.source !== nodeId && e.target !== nodeId)); + }; + return ( + + + ); +} + + +// Definitions of Nodes + +type StartNodeProps = { + id: string; + data: startNodeData; +}; + +export const StartNode= ({ id, data }: StartNodeProps) => { + return ( + <> + +
+
data test {data.label}
+ +
+ + ); +}; + +type EndNodeProps = { + id: string; + data: endNodeData; +}; + +export const EndNode= ({ id, data }: EndNodeProps) => { + return ( + <> + +
+
{data.label}
+ +
+ + ); +}; + + +type PhaseNodeProps = { + id: string; + data: phaseNodeData; +}; + +export const PhaseNode= ({ id, data }: PhaseNodeProps) => { + return ( + <> + +
+
phase {data.number} {data.label}
+ + + +
+ + ); +}; + +type NormNodeProps = { + id: string; + data: normNodeData; +}; + +export const NormNode= ({ id, data }: NormNodeProps) => { + return ( + <> + +
+
Norm {data.label}
+ +
+ + ); +}; \ No newline at end of file -- 2.49.1 From 70ebb16359f25999b77fbe83e4c6f89d7d266ca7 Mon Sep 17 00:00:00 2001 From: JGerla Date: Thu, 2 Oct 2025 12:43:44 +0200 Subject: [PATCH 021/184] feat: modified css styling for nodes to have different colors per type updated the nodes to have a different colour per NodeType, so it is easier to see what nodes are of what type in the graph. ref: N25B-114 --- src/visualProgrammingUI/VisProgUI.css | 38 +++++++++++++++++-- .../components/DragDropSidebar.tsx | 10 ++--- .../components/NodeDefinitions.tsx | 8 ++-- 3 files changed, 44 insertions(+), 12 deletions(-) diff --git a/src/visualProgrammingUI/VisProgUI.css b/src/visualProgrammingUI/VisProgUI.css index 35645a7..a9727c2 100644 --- a/src/visualProgrammingUI/VisProgUI.css +++ b/src/visualProgrammingUI/VisProgUI.css @@ -1,7 +1,39 @@ .default-node { - padding: 10px 20px; + padding: 10px 15px; background-color: white; - outline-style: solid; border-radius: 5pt; - outline-width: 1pt; + outline: black solid 2pt; + filter: drop-shadow(0 0 0.75rem black); +} + +.default-node__norm { + padding: 10px 15px; + background-color: white; + border-radius: 5pt; + outline: forestgreen solid 2pt; + filter: drop-shadow(0 0 0.25rem forestgreen); +} + +.default-node__phase { + padding: 10px 15px; + background-color: white; + border-radius: 5pt; + outline: dodgerblue solid 2pt; + filter: drop-shadow(0 0 0.25rem dodgerblue); +} + +.default-node__start { + padding: 10px 15px; + background-color: white; + border-radius: 5pt; + outline: orange solid 2pt; + filter: drop-shadow(0 0 0.25rem orange); +} + +.default-node__end { + padding: 10px 15px; + background-color: white; + border-radius: 5pt; + outline: red solid 2pt; + filter: drop-shadow(0 0 0.25rem red); } \ No newline at end of file diff --git a/src/visualProgrammingUI/components/DragDropSidebar.tsx b/src/visualProgrammingUI/components/DragDropSidebar.tsx index b3926d9..08ce022 100644 --- a/src/visualProgrammingUI/components/DragDropSidebar.tsx +++ b/src/visualProgrammingUI/components/DragDropSidebar.tsx @@ -120,20 +120,20 @@ export function Sidebar() { ); return ( - +
); } \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/NodeDefinitions.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/NodeDefinitions.tsx index 8909093..a9fd7b4 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/NodeDefinitions.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/NodeDefinitions.tsx @@ -2,7 +2,9 @@ import {Handle, NodeToolbar, Position, useReactFlow} from '@xyflow/react'; import '@xyflow/react/dist/style.css'; import '../VisProgUI.css'; -// Datatypes for NodeTypes +// Contains the datatypes for the data inside our NodeTypes +// this has to be improved or adapted to suit our implementation for computing the graph +// into a format that is useful for the Control Backend type defaultNodeData = { label: string; -- 2.49.1 From 5cbcf30f6838c25ef3f44e5162c455c7765c92cd Mon Sep 17 00:00:00 2001 From: JGerla Date: Sun, 12 Oct 2025 13:56:06 +0200 Subject: [PATCH 050/184] feat: added VisProgPage to linked pages in Home.tsx and App.tsx BREAKING: renamed Sidebar to DndToolbar. ref: N25B-114 --- src/App.tsx | 2 ++ src/pages/Home/Home.tsx | 1 + 2 files changed, 3 insertions(+) diff --git a/src/App.tsx b/src/App.tsx index 803b84c..968c979 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,7 @@ import './App.css' import TemplatePage from './pages/TemplatePage/Template.tsx' import Home from './pages/Home/Home.tsx' import Robot from './pages/Robot/Robot.tsx'; +import VisProg from "./pages/VisProgPage/VisProg.tsx"; function App(){ return ( @@ -14,6 +15,7 @@ function App(){ } /> } /> + } /> } /> diff --git a/src/pages/Home/Home.tsx b/src/pages/Home/Home.tsx index cb70de0..aff0278 100644 --- a/src/pages/Home/Home.tsx +++ b/src/pages/Home/Home.tsx @@ -12,6 +12,7 @@ function Home() {
Robot Interaction → + editor → Template →
-- 2.49.1 From dbb38f3e484a45cdb39011c8b522a1535b420d14 Mon Sep 17 00:00:00 2001 From: 2584433 Date: Fri, 17 Oct 2025 14:13:15 +0000 Subject: [PATCH 051/184] fix: macBug --- .githooks/commit-msg | 1 - .githooks/pre-commit | 1 - README.md | 2 +- 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/.githooks/commit-msg b/.githooks/commit-msg index dd14401..41992ad 100755 --- a/.githooks/commit-msg +++ b/.githooks/commit-msg @@ -5,7 +5,6 @@ commit_msg=$(cat "$commit_msg_file") if echo "$commit_msg" | grep -Eq "^(feat|fix|refactor|perf|style|test|docs|build|chore|revert): .+"; then if echo "$commit_msg" | grep -Eq "^(ref|close):\sN25B-.+"; then - echo "🎉 commit message is Valid" exit 0 else echo "❌ Commit message invalid! Must end with [ref/close]: N25B-000" diff --git a/.githooks/pre-commit b/.githooks/pre-commit index ed801d8..7e94937 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -9,7 +9,6 @@ fi # allowed pattern if echo "$branch" | grep -Eq "^(feat|fix|refactor|perf|style|test|docs|build|chore|revert)\/\w+(-\w+){0,5}$"; then - echo "✅ Branch name valid: $branch" exit 0 else echo "❌ Invalid branch name: $branch" diff --git a/README.md b/README.md index 6e97243..5646928 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ git config --local core.hooksPath .githooks ``` If your commit fails its either: -branch name != /description-of-branch +branch name != /description-of-branch , commit name != : description of the commit. : N25B-Num's -- 2.49.1 From 9235b82fa9cf19c67d28d31bea394406794c396e Mon Sep 17 00:00:00 2001 From: "Gerla, J. (Justin)" Date: Tue, 21 Oct 2025 11:59:43 +0000 Subject: [PATCH 052/184] Apply 1 suggestion(s) to 1 file(s) Co-authored-by: Twirre --- src/pages/Home/Home.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/Home/Home.tsx b/src/pages/Home/Home.tsx index aff0278..3a74d8e 100644 --- a/src/pages/Home/Home.tsx +++ b/src/pages/Home/Home.tsx @@ -12,7 +12,7 @@ function Home() {
Robot Interaction → - editor → + Editor → Template →
-- 2.49.1 From 7e739ef106444d7a2d466f0dbdd15b97b4cb00d2 Mon Sep 17 00:00:00 2001 From: JGerla Date: Tue, 21 Oct 2025 20:31:24 +0200 Subject: [PATCH 053/184] refactor: changes from feedback on merge request ref: N25B-114 --- src/pages/VisProgPage/VisProg.tsx | 7 +------ src/pages/VisProgPage/visualProgrammingUI/VisProgUI.css | 1 + .../visualProgrammingUI/components/DragDropSidebar.tsx | 1 + 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/pages/VisProgPage/VisProg.tsx b/src/pages/VisProgPage/VisProg.tsx index 104683d..785c564 100644 --- a/src/pages/VisProgPage/VisProg.tsx +++ b/src/pages/VisProgPage/VisProg.tsx @@ -1,19 +1,14 @@ -import {Link} from "react-router"; import VisProgUI from "./visualProgrammingUI/VisProgUI.tsx"; //this is your css file where you can style your buttons and such //you can still use css parts from App.css, but also overwrite them + function VisProgPage() { return ( <> - {/* here you link to the homepage, in App.tsx you can link new pages */} - - ) } diff --git a/src/pages/VisProgPage/visualProgrammingUI/VisProgUI.css b/src/pages/VisProgPage/visualProgrammingUI/VisProgUI.css index b853be4..be3d5a0 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/VisProgUI.css +++ b/src/pages/VisProgPage/visualProgrammingUI/VisProgUI.css @@ -21,6 +21,7 @@ margin-inline-start: auto; margin-inline-end: auto; background-color: canvas; + align-content: center; margin-bottom: 0.5rem; margin-top:auto; width: 50%; diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx index ce54fd2..1ca9374 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx @@ -148,6 +148,7 @@ export function DndToolbar() { borderColor: 'dimgrey', backgroundColor: 'canvas', display: 'flex', + alignItems: 'center', flexDirection: 'column', gap: '1rem' }}> -- 2.49.1 From cbacf924f9b95190549ceabd17d00570ce9c515b Mon Sep 17 00:00:00 2001 From: JGerla Date: Tue, 21 Oct 2025 22:18:10 +0200 Subject: [PATCH 054/184] chore: added basic jest testing support also added an example test for the counter in the components.tsx file to demonstrate functionality of testing configuration. installed all dependencies for testing using --save-dev to make sure they are stored as dev dependencies ref: N25B-212 --- jest.config.js | 14 + package-lock.json | 4558 +++++++++++++++++++++++++++++++++++++- package.json | 8 + test/components.test.tsx | 12 + test/setupTests.ts | 2 + tsconfig.app.json | 2 +- tsconfig.jest.json | 12 + 7 files changed, 4604 insertions(+), 4 deletions(-) create mode 100644 jest.config.js create mode 100644 test/components.test.tsx create mode 100644 test/setupTests.ts create mode 100644 tsconfig.jest.json diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..ea33067 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,14 @@ +export default { + preset: 'ts-jest/presets/default-esm', + testEnvironment: 'jsdom', + extensionsToTreatAsEsm: ['.ts', '.tsx'], + setupFilesAfterEnv: ['/test/setupTests.ts'], + moduleNameMapper: { + '^@/(.*)$': '/src/$1', + '\\.(css|scss|sass)$': 'identity-obj-proxy' + }, + testMatch: ['/test/*.test.(ts|tsx)'], + transform: { + '^.+\\.(ts|tsx)$': ['ts-jest', { useESM: true, tsconfig: 'tsconfig.jest.json' }] + } +}; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 0b92f5c..11ca4e5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,10 @@ }, "devDependencies": { "@eslint/js": "^9.36.0", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.1", + "@types/jest": "^30.0.0", "@types/react": "^19.1.13", "@types/react-dom": "^19.1.9", "@vitejs/plugin-react": "^5.0.3", @@ -21,11 +25,43 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", "globals": "^16.4.0", + "identity-obj-proxy": "^3.0.0", + "jest": "^30.2.0", + "jest-environment-jsdom": "^30.2.0", + "ts-jest": "^29.4.5", "typescript": "~5.8.3", "typescript-eslint": "^8.44.0", "vite": "^7.1.7" } }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -228,6 +264,245 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz", + "integrity": "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz", + "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz", + "integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/plugin-transform-react-jsx-self": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", @@ -260,6 +535,16 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.27.2", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", @@ -308,6 +593,162 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@emnapi/core": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.6.0.tgz", + "integrity": "sha512-zq/ay+9fNIJJtJiZxdTnXS20PllcYMX3OE23ESc4HK/bdYu3cOWYVhsOhVnXALfU/uqJIxn5NBPd9z4v+SfoSg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.6.0.tgz", + "integrity": "sha512-obtUmAHTMjll499P+D9A3axeJFlhdjOWdKUNs/U6QIGT7V5RjcUW1xToAzjvmgTSQhDbYn/NwfTRoJcQ2rNBxA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", + "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.10", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.10.tgz", @@ -956,6 +1397,511 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.2.0.tgz", + "integrity": "sha512-+O1ifRjkvYIkBqASKWgLxrpEhQAAE7hY77ALLUufSk5717KfOShg6IbqLmdsLMPdUiFvA2kTs0R7YZy+l0IzZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.2.0", + "@types/node": "*", + "chalk": "^4.1.2", + "jest-message-util": "30.2.0", + "jest-util": "30.2.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/core": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.2.0.tgz", + "integrity": "sha512-03W6IhuhjqTlpzh/ojut/pDB2LPRygyWX8ExpgHtQA8H/3K7+1vKmcINx5UzeOX1se6YEsBsOHQ1CRzf3fOwTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "30.2.0", + "@jest/pattern": "30.0.1", + "@jest/reporters": "30.2.0", + "@jest/test-result": "30.2.0", + "@jest/transform": "30.2.0", + "@jest/types": "30.2.0", + "@types/node": "*", + "ansi-escapes": "^4.3.2", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "exit-x": "^0.2.2", + "graceful-fs": "^4.2.11", + "jest-changed-files": "30.2.0", + "jest-config": "30.2.0", + "jest-haste-map": "30.2.0", + "jest-message-util": "30.2.0", + "jest-regex-util": "30.0.1", + "jest-resolve": "30.2.0", + "jest-resolve-dependencies": "30.2.0", + "jest-runner": "30.2.0", + "jest-runtime": "30.2.0", + "jest-snapshot": "30.2.0", + "jest-util": "30.2.0", + "jest-validate": "30.2.0", + "jest-watcher": "30.2.0", + "micromatch": "^4.0.8", + "pretty-format": "30.2.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/diff-sequences": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.0.1.tgz", + "integrity": "sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.2.0.tgz", + "integrity": "sha512-/QPTL7OBJQ5ac09UDRa3EQes4gt1FTEG/8jZ/4v5IVzx+Cv7dLxlVIvfvSVRiiX2drWyXeBjkMSR8hvOWSog5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "30.2.0", + "@jest/types": "30.2.0", + "@types/node": "*", + "jest-mock": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/environment-jsdom-abstract/-/environment-jsdom-abstract-30.2.0.tgz", + "integrity": "sha512-kazxw2L9IPuZpQ0mEt9lu9Z98SqR74xcagANmMBU16X0lS23yPc0+S6hGLUz8kVRlomZEs/5S/Zlpqwf5yu6OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.2.0", + "@jest/fake-timers": "30.2.0", + "@jest/types": "30.2.0", + "@types/jsdom": "^21.1.7", + "@types/node": "*", + "jest-mock": "30.2.0", + "jest-util": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/@jest/expect": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.2.0.tgz", + "integrity": "sha512-V9yxQK5erfzx99Sf+7LbhBwNWEZ9eZay8qQ9+JSC0TrMR1pMDHLMY+BnVPacWU6Jamrh252/IKo4F1Xn/zfiqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "30.2.0", + "jest-snapshot": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.2.0.tgz", + "integrity": "sha512-1JnRfhqpD8HGpOmQp180Fo9Zt69zNtC+9lR+kT7NVL05tNXIi+QC8Csz7lfidMoVLPD3FnOtcmp0CEFnxExGEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.2.0.tgz", + "integrity": "sha512-HI3tRLjRxAbBy0VO8dqqm7Hb2mIa8d5bg/NJkyQcOk7V118ObQML8RC5luTF/Zsg4474a+gDvhce7eTnP4GhYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.2.0", + "@sinonjs/fake-timers": "^13.0.0", + "@types/node": "*", + "jest-message-util": "30.2.0", + "jest-mock": "30.2.0", + "jest-util": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/get-type": { + "version": "30.1.0", + "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz", + "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.2.0.tgz", + "integrity": "sha512-b63wmnKPaK+6ZZfpYhz9K61oybvbI1aMcIs80++JI1O1rR1vaxHUCNqo3ITu6NU0d4V34yZFoHMn/uoKr/Rwfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.2.0", + "@jest/expect": "30.2.0", + "@jest/types": "30.2.0", + "jest-mock": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/pattern": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.0.1.tgz", + "integrity": "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-regex-util": "30.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.2.0.tgz", + "integrity": "sha512-DRyW6baWPqKMa9CzeiBjHwjd8XeAyco2Vt8XbcLFjiwCOEKOvy82GJ8QQnJE9ofsxCMPjH4MfH8fCWIHHDKpAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "30.2.0", + "@jest/test-result": "30.2.0", + "@jest/transform": "30.2.0", + "@jest/types": "30.2.0", + "@jridgewell/trace-mapping": "^0.3.25", + "@types/node": "*", + "chalk": "^4.1.2", + "collect-v8-coverage": "^1.0.2", + "exit-x": "^0.2.2", + "glob": "^10.3.10", + "graceful-fs": "^4.2.11", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^5.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "30.2.0", + "jest-util": "30.2.0", + "jest-worker": "30.2.0", + "slash": "^3.0.0", + "string-length": "^4.0.2", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/schemas": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", + "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/snapshot-utils": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.2.0.tgz", + "integrity": "sha512-0aVxM3RH6DaiLcjj/b0KrIBZhSX1373Xci4l3cW5xiUWPctZ59zQ7jj4rqcJQ/Z8JuN/4wX3FpJSa3RssVvCug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.2.0", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "natural-compare": "^1.4.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-30.0.1.tgz", + "integrity": "sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "callsites": "^3.1.0", + "graceful-fs": "^4.2.11" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/test-result": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.2.0.tgz", + "integrity": "sha512-RF+Z+0CCHkARz5HT9mcQCBulb1wgCP3FBvl9VFokMX27acKphwyQsNuWH3c+ojd1LeWBLoTYoxF0zm6S/66mjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "30.2.0", + "@jest/types": "30.2.0", + "@types/istanbul-lib-coverage": "^2.0.6", + "collect-v8-coverage": "^1.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.2.0.tgz", + "integrity": "sha512-wXKgU/lk8fKXMu/l5Hog1R61bL4q5GCdT6OJvdAFz1P+QrpoFuLU68eoKuVc4RbrTtNnTL5FByhWdLgOPSph+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "30.2.0", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.2.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.2.0.tgz", + "integrity": "sha512-XsauDV82o5qXbhalKxD7p4TZYYdwcaEXC77PPD2HixEFF+6YGppjrAAQurTl2ECWcEomHBMMNS9AH3kcCFx8jA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@jest/types": "30.2.0", + "@jridgewell/trace-mapping": "^0.3.25", + "babel-plugin-istanbul": "^7.0.1", + "chalk": "^4.1.2", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.2.0", + "jest-regex-util": "30.0.1", + "jest-util": "30.2.0", + "micromatch": "^4.0.8", + "pirates": "^4.0.7", + "slash": "^3.0.0", + "write-file-atomic": "^5.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/types": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.2.0.tgz", + "integrity": "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.0.1", + "@jest/schemas": "30.0.5", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1006,6 +1952,19 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1044,6 +2003,30 @@ "node": ">= 8" } }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@pkgr/core": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", + "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.35", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.35.tgz", @@ -1359,6 +2342,191 @@ "win32" ] }, + "node_modules/@sinclair/typebox": { + "version": "0.34.41", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz", + "integrity": "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "13.0.5", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", + "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@testing-library/dom/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@testing-library/dom/node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@testing-library/dom/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.0.tgz", + "integrity": "sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1411,6 +2579,56 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/jest": { + "version": "30.0.0", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-30.0.0.tgz", + "integrity": "sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^30.0.0", + "pretty-format": "^30.0.0" + } + }, + "node_modules/@types/jsdom": { + "version": "21.1.7", + "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-21.1.7.tgz", + "integrity": "sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/tough-cookie": "*", + "parse5": "^7.0.0" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1418,6 +2636,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "24.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz", + "integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, "node_modules/@types/react": { "version": "19.1.13", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.13.tgz", @@ -1438,6 +2666,37 @@ "@types/react": "^19.0.0" } }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/yargs": { + "version": "17.0.33", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", + "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.44.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.44.1.tgz", @@ -1709,6 +2968,282 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true, + "license": "ISC" + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", + "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", + "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", + "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", + "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", + "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", + "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", + "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", + "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", + "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", + "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", + "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", + "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", + "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", + "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", + "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", + "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.11" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", + "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", + "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", + "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@vitejs/plugin-react": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.0.3.tgz", @@ -1753,6 +3288,16 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -1770,6 +3315,35 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -1786,6 +3360,20 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -1793,6 +3381,115 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/babel-jest": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.2.0.tgz", + "integrity": "sha512-0YiBEOxWqKkSQWL9nNGGEgndoeL0ZpWrbLMNL5u/Kaxrli3Eaxlt3ZtIDktEvXt4L/R9r3ODr2zKwGM/2BjxVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/transform": "30.2.0", + "@types/babel__core": "^7.20.5", + "babel-plugin-istanbul": "^7.0.1", + "babel-preset-jest": "30.2.0", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.11.0 || ^8.0.0-0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-7.0.1.tgz", + "integrity": "sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA==", + "dev": true, + "license": "BSD-3-Clause", + "workspaces": [ + "test/babel-8" + ], + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-instrument": "^6.0.2", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.2.0.tgz", + "integrity": "sha512-ftzhzSGMUnOzcCXd6WHdBGMyuwy15Wnn0iyyWGKgBDLxf9/s5ABuraCSpBX2uG0jUg4rqJnxsLc5+oYBqoxVaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/babel__core": "^7.20.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", + "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/babel-preset-jest": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.2.0.tgz", + "integrity": "sha512-US4Z3NOieAQumwFnYdUWKvUKh8+YSnS/gB3t6YBiz0bskpu7Pine8pPCheNxlPEW4wnUkma2a94YuW2q3guvCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "30.2.0", + "babel-preset-current-node-syntax": "^1.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.11.0 || ^8.0.0-beta.1" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1868,6 +3565,36 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/bs-logger": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-json-stable-stringify": "2.x" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -1878,6 +3605,16 @@ "node": ">=6" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001745", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001745.tgz", @@ -1916,6 +3653,135 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ci-info": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", + "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cjs-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.1.0.tgz", + "integrity": "sha512-UX0OwmYRYQQetfrLEZeewIFFI+wSTofC+pMBLNuH3RUuu/xzG1oz84UCEDOSoQlN3fZ4+AzmV50ZYvGqkMh9yA==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", + "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", + "dev": true, + "license": "MIT" + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -1974,6 +3840,27 @@ "node": ">= 8" } }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", @@ -1981,6 +3868,20 @@ "dev": true, "license": "MIT" }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1999,6 +3900,28 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/dedent": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.0.tgz", + "integrity": "sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2006,6 +3929,51 @@ "dev": true, "license": "MIT" }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.223", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.223.tgz", @@ -2013,6 +3981,49 @@ "dev": true, "license": "ISC" }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, "node_modules/esbuild": { "version": "0.25.10", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz", @@ -2210,6 +4221,20 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/esquery": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", @@ -2256,6 +4281,65 @@ "node": ">=0.10.0" } }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/execa/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/exit-x": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/exit-x/-/exit-x-0.2.2.tgz", + "integrity": "sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expect": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.2.0.tgz", + "integrity": "sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "30.2.0", + "@jest/get-type": "30.1.0", + "jest-matcher-utils": "30.2.0", + "jest-message-util": "30.2.0", + "jest-mock": "30.2.0", + "jest-util": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2317,6 +4401,16 @@ "reusify": "^1.0.4" } }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bser": "2.1.1" + } + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -2381,6 +4475,30 @@ "dev": true, "license": "ISC" }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2406,6 +4524,60 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -2419,6 +4591,32 @@ "node": ">=10.13.0" } }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/globals": { "version": "16.4.0", "resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz", @@ -2432,6 +4630,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, "node_modules/graphemer": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", @@ -2439,6 +4644,35 @@ "dev": true, "license": "MIT" }, + "node_modules/handlebars": { + "version": "4.7.8", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", + "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, + "node_modules/harmony-reflect": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/harmony-reflect/-/harmony-reflect-1.6.2.tgz", + "integrity": "sha512-HIp/n38R9kQjDEziXyDTuW3vvoxxyxjxFzXLrBr18uB47GnSt+G9D29fqrpM5ZkspMcPICud3XsBJQ4Y2URg8g==", + "dev": true, + "license": "(Apache-2.0 OR MPL-1.1)" + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -2449,6 +4683,90 @@ "node": ">=8" } }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/identity-obj-proxy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/identity-obj-proxy/-/identity-obj-proxy-3.0.0.tgz", + "integrity": "sha512-00n6YnVHKrinT9t0d9+5yZC6UBNJANpYEQvL2LlX6Ab9lnmxzIRcEmTPuyGScvl1+jKuCICX1Z0Ab1pPKKdikA==", + "dev": true, + "license": "MIT", + "dependencies": { + "harmony-reflect": "^1.4.6" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2476,6 +4794,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -2486,6 +4824,42 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -2496,6 +4870,26 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -2519,6 +4913,26 @@ "node": ">=0.12.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -2526,6 +4940,737 @@ "dev": true, "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-instrument/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jest": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-30.2.0.tgz", + "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "30.2.0", + "@jest/types": "30.2.0", + "import-local": "^3.2.0", + "jest-cli": "30.2.0" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.2.0.tgz", + "integrity": "sha512-L8lR1ChrRnSdfeOvTrwZMlnWV8G/LLjQ0nG9MBclwWZidA2N5FviRki0Bvh20WRMOX31/JYvzdqTJrk5oBdydQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^5.1.1", + "jest-util": "30.2.0", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-circus": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.2.0.tgz", + "integrity": "sha512-Fh0096NC3ZkFx05EP2OXCxJAREVxj1BcW/i6EWqqymcgYKWjyyDpral3fMxVcHXg6oZM7iULer9wGRFvfpl+Tg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.2.0", + "@jest/expect": "30.2.0", + "@jest/test-result": "30.2.0", + "@jest/types": "30.2.0", + "@types/node": "*", + "chalk": "^4.1.2", + "co": "^4.6.0", + "dedent": "^1.6.0", + "is-generator-fn": "^2.1.0", + "jest-each": "30.2.0", + "jest-matcher-utils": "30.2.0", + "jest-message-util": "30.2.0", + "jest-runtime": "30.2.0", + "jest-snapshot": "30.2.0", + "jest-util": "30.2.0", + "p-limit": "^3.1.0", + "pretty-format": "30.2.0", + "pure-rand": "^7.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-cli": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.2.0.tgz", + "integrity": "sha512-Os9ukIvADX/A9sLt6Zse3+nmHtHaE6hqOsjQtNiugFTbKRHYIYtZXNGNK9NChseXy7djFPjndX1tL0sCTlfpAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "30.2.0", + "@jest/test-result": "30.2.0", + "@jest/types": "30.2.0", + "chalk": "^4.1.2", + "exit-x": "^0.2.2", + "import-local": "^3.2.0", + "jest-config": "30.2.0", + "jest-util": "30.2.0", + "jest-validate": "30.2.0", + "yargs": "^17.7.2" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-config": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.2.0.tgz", + "integrity": "sha512-g4WkyzFQVWHtu6uqGmQR4CQxz/CH3yDSlhzXMWzNjDx843gYjReZnMRanjRCq5XZFuQrGDxgUaiYWE8BRfVckA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@jest/get-type": "30.1.0", + "@jest/pattern": "30.0.1", + "@jest/test-sequencer": "30.2.0", + "@jest/types": "30.2.0", + "babel-jest": "30.2.0", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "deepmerge": "^4.3.1", + "glob": "^10.3.10", + "graceful-fs": "^4.2.11", + "jest-circus": "30.2.0", + "jest-docblock": "30.2.0", + "jest-environment-node": "30.2.0", + "jest-regex-util": "30.0.1", + "jest-resolve": "30.2.0", + "jest-runner": "30.2.0", + "jest-util": "30.2.0", + "jest-validate": "30.2.0", + "micromatch": "^4.0.8", + "parse-json": "^5.2.0", + "pretty-format": "30.2.0", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "esbuild-register": ">=3.4.0", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "esbuild-register": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-diff": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.2.0.tgz", + "integrity": "sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/diff-sequences": "30.0.1", + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "pretty-format": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-docblock": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.2.0.tgz", + "integrity": "sha512-tR/FFgZKS1CXluOQzZvNH3+0z9jXr3ldGSD8bhyuxvlVUwbeLOGynkunvlTMxchC5urrKndYiwCFC0DLVjpOCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-newline": "^3.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-each": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.2.0.tgz", + "integrity": "sha512-lpWlJlM7bCUf1mfmuqTA8+j2lNURW9eNafOy99knBM01i5CQeY5UH1vZjgT9071nDJac1M4XsbyI44oNOdhlDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "@jest/types": "30.2.0", + "chalk": "^4.1.2", + "jest-util": "30.2.0", + "pretty-format": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-jsdom": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-30.2.0.tgz", + "integrity": "sha512-zbBTiqr2Vl78pKp/laGBREYzbZx9ZtqPjOK4++lL4BNDhxRnahg51HtoDrk9/VjIy9IthNEWdKVd7H5bqBhiWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.2.0", + "@jest/environment-jsdom-abstract": "30.2.0", + "@types/jsdom": "^21.1.7", + "@types/node": "*", + "jsdom": "^26.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jest-environment-node": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.2.0.tgz", + "integrity": "sha512-ElU8v92QJ9UrYsKrxDIKCxu6PfNj4Hdcktcn0JX12zqNdqWHB0N+hwOnnBBXvjLd2vApZtuLUGs1QSY+MsXoNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.2.0", + "@jest/fake-timers": "30.2.0", + "@jest/types": "30.2.0", + "@types/node": "*", + "jest-mock": "30.2.0", + "jest-util": "30.2.0", + "jest-validate": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.2.0.tgz", + "integrity": "sha512-sQA/jCb9kNt+neM0anSj6eZhLZUIhQgwDt7cPGjumgLM4rXsfb9kpnlacmvZz3Q5tb80nS+oG/if+NBKrHC+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.2.0", + "@types/node": "*", + "anymatch": "^3.1.3", + "fb-watchman": "^2.0.2", + "graceful-fs": "^4.2.11", + "jest-regex-util": "30.0.1", + "jest-util": "30.2.0", + "jest-worker": "30.2.0", + "micromatch": "^4.0.8", + "walker": "^1.0.8" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.3" + } + }, + "node_modules/jest-leak-detector": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.2.0.tgz", + "integrity": "sha512-M6jKAjyzjHG0SrQgwhgZGy9hFazcudwCNovY/9HPIicmNSBuockPSedAP9vlPK6ONFJ1zfyH/M2/YYJxOz5cdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "pretty-format": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.2.0.tgz", + "integrity": "sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "jest-diff": "30.2.0", + "pretty-format": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.2.0.tgz", + "integrity": "sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.2.0", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "micromatch": "^4.0.8", + "pretty-format": "30.2.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-mock": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.2.0.tgz", + "integrity": "sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.2.0", + "@types/node": "*", + "jest-util": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "node_modules/jest-regex-util": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.0.1.tgz", + "integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.2.0.tgz", + "integrity": "sha512-TCrHSxPlx3tBY3hWNtRQKbtgLhsXa1WmbJEqBlTBrGafd5fiQFByy2GNCEoGR+Tns8d15GaL9cxEzKOO3GEb2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.2.0", + "jest-pnp-resolver": "^1.2.3", + "jest-util": "30.2.0", + "jest-validate": "30.2.0", + "slash": "^3.0.0", + "unrs-resolver": "^1.7.11" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.2.0.tgz", + "integrity": "sha512-xTOIGug/0RmIe3mmCqCT95yO0vj6JURrn1TKWlNbhiAefJRWINNPgwVkrVgt/YaerPzY3iItufd80v3lOrFJ2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-regex-util": "30.0.1", + "jest-snapshot": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-runner": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.2.0.tgz", + "integrity": "sha512-PqvZ2B2XEyPEbclp+gV6KO/F1FIFSbIwewRgmROCMBo/aZ6J1w8Qypoj2pEOcg3G2HzLlaP6VUtvwCI8dM3oqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "30.2.0", + "@jest/environment": "30.2.0", + "@jest/test-result": "30.2.0", + "@jest/transform": "30.2.0", + "@jest/types": "30.2.0", + "@types/node": "*", + "chalk": "^4.1.2", + "emittery": "^0.13.1", + "exit-x": "^0.2.2", + "graceful-fs": "^4.2.11", + "jest-docblock": "30.2.0", + "jest-environment-node": "30.2.0", + "jest-haste-map": "30.2.0", + "jest-leak-detector": "30.2.0", + "jest-message-util": "30.2.0", + "jest-resolve": "30.2.0", + "jest-runtime": "30.2.0", + "jest-util": "30.2.0", + "jest-watcher": "30.2.0", + "jest-worker": "30.2.0", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-runtime": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.2.0.tgz", + "integrity": "sha512-p1+GVX/PJqTucvsmERPMgCPvQJpFt4hFbM+VN3n8TMo47decMUcJbt+rgzwrEme0MQUA/R+1de2axftTHkKckg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.2.0", + "@jest/fake-timers": "30.2.0", + "@jest/globals": "30.2.0", + "@jest/source-map": "30.0.1", + "@jest/test-result": "30.2.0", + "@jest/transform": "30.2.0", + "@jest/types": "30.2.0", + "@types/node": "*", + "chalk": "^4.1.2", + "cjs-module-lexer": "^2.1.0", + "collect-v8-coverage": "^1.0.2", + "glob": "^10.3.10", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.2.0", + "jest-message-util": "30.2.0", + "jest-mock": "30.2.0", + "jest-regex-util": "30.0.1", + "jest-resolve": "30.2.0", + "jest-snapshot": "30.2.0", + "jest-util": "30.2.0", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-snapshot": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.2.0.tgz", + "integrity": "sha512-5WEtTy2jXPFypadKNpbNkZ72puZCa6UjSr/7djeecHWOu7iYhSXSnHScT8wBz3Rn8Ena5d5RYRcsyKIeqG1IyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@babel/generator": "^7.27.5", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.27.1", + "@babel/types": "^7.27.3", + "@jest/expect-utils": "30.2.0", + "@jest/get-type": "30.1.0", + "@jest/snapshot-utils": "30.2.0", + "@jest/transform": "30.2.0", + "@jest/types": "30.2.0", + "babel-preset-current-node-syntax": "^1.2.0", + "chalk": "^4.1.2", + "expect": "30.2.0", + "graceful-fs": "^4.2.11", + "jest-diff": "30.2.0", + "jest-matcher-utils": "30.2.0", + "jest-message-util": "30.2.0", + "jest-util": "30.2.0", + "pretty-format": "30.2.0", + "semver": "^7.7.2", + "synckit": "^0.11.8" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-snapshot/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jest-util": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.2.0.tgz", + "integrity": "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.2.0", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-util/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/jest-validate": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.2.0.tgz", + "integrity": "sha512-FBGWi7dP2hpdi8nBoWxSsLvBFewKAg0+uSQwBaof4Y4DPgBabXgpSYC5/lR7VmnIlSpASmCi/ntRWPbv7089Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "@jest/types": "30.2.0", + "camelcase": "^6.3.0", + "chalk": "^4.1.2", + "leven": "^3.1.0", + "pretty-format": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-validate/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-watcher": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.2.0.tgz", + "integrity": "sha512-PYxa28dxJ9g777pGm/7PrbnMeA0Jr7osHP9bS7eJy9DuAjMgdGtxgf0uKMyoIsTWAkIbUW5hSDdJ3urmgXBqxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "30.2.0", + "@jest/types": "30.2.0", + "@types/node": "*", + "ansi-escapes": "^4.3.2", + "chalk": "^4.1.2", + "emittery": "^0.13.1", + "jest-util": "30.2.0", + "string-length": "^4.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-worker": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.2.0.tgz", + "integrity": "sha512-0Q4Uk8WF7BUwqXHuAjc23vmopWJw5WH7w2tqBoUOZpOjW/ZnR44GXXd1r82RvnmI2GZge3ivrYXk/BE2+VtW2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@ungap/structured-clone": "^1.3.0", + "jest-util": "30.2.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.1.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -2546,6 +5691,46 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -2566,6 +5751,13 @@ "dev": true, "license": "MIT" }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -2603,6 +5795,16 @@ "json-buffer": "3.0.1" } }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -2617,6 +5819,13 @@ "node": ">= 0.8.0" } }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -2633,6 +5842,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -2650,6 +5866,70 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -2674,6 +5954,26 @@ "node": ">=8.6" } }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -2687,6 +5987,26 @@ "node": "*" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2713,6 +6033,22 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -2720,6 +6056,20 @@ "dev": true, "license": "MIT" }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT" + }, "node_modules/node-releases": { "version": "2.0.21", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.21.tgz", @@ -2727,6 +6077,62 @@ "dev": true, "license": "MIT" }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nwsapi": { + "version": "2.2.22", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.22.tgz", + "integrity": "sha512-ujSMe1OWVn55euT1ihwCI1ZcAaAU3nxUiDwfDQldc51ZXaB9m2AyOn6/jh1BLe2t/G8xd6uKG1UBF2aZJeg2SQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -2777,6 +6183,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -2790,6 +6213,38 @@ "node": ">=6" } }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -2800,6 +6255,16 @@ "node": ">=8" } }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -2810,6 +6275,30 @@ "node": ">=8" } }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2830,6 +6319,85 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pkg-dir/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -2869,6 +6437,34 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", + "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.0.5", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -2879,6 +6475,23 @@ "node": ">=6" } }, + "node_modules/pure-rand": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", + "integrity": "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -2921,6 +6534,13 @@ "react": "^19.1.1" } }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -2953,6 +6573,53 @@ } } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-cwd/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -3016,6 +6683,13 @@ "fsevents": "~2.3.2" } }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -3040,6 +6714,26 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.26.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", @@ -3085,6 +6779,39 @@ "node": ">=8" } }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -3095,6 +6822,221 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-length/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-length/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -3121,6 +7063,66 @@ "node": ">=8" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/synckit": { + "version": "0.11.11", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz", + "integrity": "sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pkgr/core": "^0.2.9" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/synckit" + } + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -3169,6 +7171,33 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -3182,6 +7211,32 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/ts-api-utils": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", @@ -3195,6 +7250,93 @@ "typescript": ">=4.8.4" } }, + "node_modules/ts-jest": { + "version": "29.4.5", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.5.tgz", + "integrity": "sha512-HO3GyiWn2qvTQA4kTgjDcXiMwYQt68a1Y8+JuLRVpdIzm+UOLSHgl/XqR4c6nzJkq5rOkjc02O2I7P7l/Yof0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "bs-logger": "^0.2.6", + "fast-json-stable-stringify": "^2.1.0", + "handlebars": "^4.7.8", + "json5": "^2.2.3", + "lodash.memoize": "^4.1.2", + "make-error": "^1.3.6", + "semver": "^7.7.3", + "type-fest": "^4.41.0", + "yargs-parser": "^21.1.1" + }, + "bin": { + "ts-jest": "cli.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "@babel/core": ">=7.0.0-beta.0 <8", + "@jest/transform": "^29.0.0 || ^30.0.0", + "@jest/types": "^29.0.0 || ^30.0.0", + "babel-jest": "^29.0.0 || ^30.0.0", + "jest": "^29.0.0 || ^30.0.0", + "jest-util": "^29.0.0 || ^30.0.0", + "typescript": ">=4.3 <6" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "@jest/transform": { + "optional": true + }, + "@jest/types": { + "optional": true + }, + "babel-jest": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jest-util": { + "optional": true + } + } + }, + "node_modules/ts-jest/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ts-jest/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -3208,6 +7350,29 @@ "node": ">= 0.8.0" } }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typescript": { "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", @@ -3246,6 +7411,62 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/unrs-resolver": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", + "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.0" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.11.1", + "@unrs/resolver-binding-android-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-x64": "1.11.1", + "@unrs/resolver-binding-freebsd-x64": "1.11.1", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", + "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-musl": "1.11.1", + "@unrs/resolver-binding-wasm32-wasi": "1.11.1", + "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", + "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", + "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" + } + }, "node_modules/update-browserslist-db": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", @@ -3287,10 +7508,25 @@ "punycode": "^2.1.0" } }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, "node_modules/vite": { - "version": "7.1.7", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.7.tgz", - "integrity": "sha512-VbA8ScMvAISJNJVbRDTJdCwqQoAareR/wutevKanhR2/1EkoXVZVkkORaYm/tNVCjP/UDTKtcw3bAkwOUdedmA==", + "version": "7.1.11", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.11.tgz", + "integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==", "dev": true, "license": "MIT", "dependencies": { @@ -3393,6 +7629,76 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -3419,6 +7725,178 @@ "node": ">=0.10.0" } }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/write-file-atomic": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz", + "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -3426,6 +7904,80 @@ "dev": true, "license": "ISC" }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 6070bd8..10b7a45 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,10 @@ }, "devDependencies": { "@eslint/js": "^9.36.0", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.1", + "@types/jest": "^30.0.0", "@types/react": "^19.1.13", "@types/react-dom": "^19.1.9", "@vitejs/plugin-react": "^5.0.3", @@ -23,6 +27,10 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", "globals": "^16.4.0", + "identity-obj-proxy": "^3.0.0", + "jest": "^30.2.0", + "jest-environment-jsdom": "^30.2.0", + "ts-jest": "^29.4.5", "typescript": "~5.8.3", "typescript-eslint": "^8.44.0", "vite": "^7.1.7" diff --git a/test/components.test.tsx b/test/components.test.tsx new file mode 100644 index 0000000..c1a39bb --- /dev/null +++ b/test/components.test.tsx @@ -0,0 +1,12 @@ +import userEvent from '@testing-library/user-event'; +import { render, screen} from '@testing-library/react'; +import Counter from '../src/components/components'; + +describe('Counter component', () => { + test('increments count', async () => { + render(); + const button = screen.getByRole('button', { name: /count is 0/i }); + await userEvent.click(button); + expect(screen.getByText(/count is 1/i)).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/test/setupTests.ts b/test/setupTests.ts new file mode 100644 index 0000000..748f491 --- /dev/null +++ b/test/setupTests.ts @@ -0,0 +1,2 @@ +// Adds jest-dom matchers for React testing library +import '@testing-library/jest-dom'; \ No newline at end of file diff --git a/tsconfig.app.json b/tsconfig.app.json index a9b5a59..bbf4e3b 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -24,5 +24,5 @@ "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true }, - "include": ["src"] + "include": ["src","test"] } diff --git a/tsconfig.jest.json b/tsconfig.jest.json new file mode 100644 index 0000000..1bc5060 --- /dev/null +++ b/tsconfig.jest.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.app.json", + "compilerOptions": { + "verbatimModuleSyntax": false, + "jsx": "react-jsx", + "module": "ESNext", + "target": "ES2022", + "allowJs": true, + "esModuleInterop": true + }, + "include": ["test"] +} \ No newline at end of file -- 2.49.1 From af7eb5ee73fc97e5e4138c0c43c31db382134845 Mon Sep 17 00:00:00 2001 From: JGerla Date: Wed, 22 Oct 2025 11:50:29 +0200 Subject: [PATCH 055/184] build: added moved depencies for merge with dev ref: N25B-114 --- package-lock.json | 70 +++++++++++++++++++++++------------------------ 1 file changed, 35 insertions(+), 35 deletions(-) diff --git a/package-lock.json b/package-lock.json index 58cdf17..a1ed79f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1955,12 +1955,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@neodrag/react": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@neodrag/react/-/react-2.3.1.tgz", - "integrity": "sha512-mOVefo3mFmaVLs9PB5F5wMXnnclG81qjOaPHyf8YZUnw/Ciz0pAqyJDwDJk0nPTIK5I2x1JdjXSchGNdCxZNRQ==", - "license": "MIT" - }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -1974,6 +1968,12 @@ "@tybys/wasm-util": "^0.10.0" } }, + "node_modules/@neodrag/react": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@neodrag/react/-/react-2.3.1.tgz", + "integrity": "sha512-mOVefo3mFmaVLs9PB5F5wMXnnclG81qjOaPHyf8YZUnw/Ciz0pAqyJDwDJk0nPTIK5I2x1JdjXSchGNdCxZNRQ==", + "license": "MIT" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -3771,12 +3771,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/classcat": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", - "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", - "license": "MIT" - }, "node_modules/char-regex": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", @@ -3810,6 +3804,12 @@ "dev": true, "license": "MIT" }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -3992,20 +3992,6 @@ "devOptional": true, "license": "MIT" }, - "node_modules/data-urls": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", - "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", - "dev": true, - "license": "MIT", - "dependencies": { - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^14.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/d3-color": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", @@ -4111,6 +4097,20 @@ "node": ">=12" } }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -7737,6 +7737,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", + "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", @@ -7752,15 +7761,6 @@ "node": ">=10.12.0" } }, - "node_modules/use-sync-external-store": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", - "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", - "license": "MIT", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, "node_modules/vite": { "version": "7.1.11", "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.11.tgz", -- 2.49.1 From 5a6bcb92982fdfe6f7090902afd24dc9582dd847 Mon Sep 17 00:00:00 2001 From: JGerla Date: Wed, 22 Oct 2025 11:50:29 +0200 Subject: [PATCH 056/184] build: moved dependencies for merge with dev ref: N25B-114 --- package-lock.json | 70 +++++++++++++++++++++++------------------------ 1 file changed, 35 insertions(+), 35 deletions(-) diff --git a/package-lock.json b/package-lock.json index 58cdf17..a1ed79f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1955,12 +1955,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@neodrag/react": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@neodrag/react/-/react-2.3.1.tgz", - "integrity": "sha512-mOVefo3mFmaVLs9PB5F5wMXnnclG81qjOaPHyf8YZUnw/Ciz0pAqyJDwDJk0nPTIK5I2x1JdjXSchGNdCxZNRQ==", - "license": "MIT" - }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -1974,6 +1968,12 @@ "@tybys/wasm-util": "^0.10.0" } }, + "node_modules/@neodrag/react": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@neodrag/react/-/react-2.3.1.tgz", + "integrity": "sha512-mOVefo3mFmaVLs9PB5F5wMXnnclG81qjOaPHyf8YZUnw/Ciz0pAqyJDwDJk0nPTIK5I2x1JdjXSchGNdCxZNRQ==", + "license": "MIT" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -3771,12 +3771,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/classcat": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", - "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", - "license": "MIT" - }, "node_modules/char-regex": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", @@ -3810,6 +3804,12 @@ "dev": true, "license": "MIT" }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -3992,20 +3992,6 @@ "devOptional": true, "license": "MIT" }, - "node_modules/data-urls": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", - "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", - "dev": true, - "license": "MIT", - "dependencies": { - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^14.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/d3-color": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", @@ -4111,6 +4097,20 @@ "node": ">=12" } }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -7737,6 +7737,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", + "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", @@ -7752,15 +7761,6 @@ "node": ">=10.12.0" } }, - "node_modules/use-sync-external-store": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", - "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", - "license": "MIT", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, "node_modules/vite": { "version": "7.1.11", "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.11.tgz", -- 2.49.1 From d3c327c10019dc561d7f971f94fe2e097641ee4b Mon Sep 17 00:00:00 2001 From: JGerla Date: Wed, 22 Oct 2025 11:53:30 +0200 Subject: [PATCH 057/184] fixup! build: moved dependencies for merge with dev --- package-lock.json | 70 +++++++++++++++++++++++------------------------ 1 file changed, 35 insertions(+), 35 deletions(-) diff --git a/package-lock.json b/package-lock.json index a1ed79f..58cdf17 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1955,6 +1955,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@neodrag/react": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@neodrag/react/-/react-2.3.1.tgz", + "integrity": "sha512-mOVefo3mFmaVLs9PB5F5wMXnnclG81qjOaPHyf8YZUnw/Ciz0pAqyJDwDJk0nPTIK5I2x1JdjXSchGNdCxZNRQ==", + "license": "MIT" + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -1968,12 +1974,6 @@ "@tybys/wasm-util": "^0.10.0" } }, - "node_modules/@neodrag/react": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@neodrag/react/-/react-2.3.1.tgz", - "integrity": "sha512-mOVefo3mFmaVLs9PB5F5wMXnnclG81qjOaPHyf8YZUnw/Ciz0pAqyJDwDJk0nPTIK5I2x1JdjXSchGNdCxZNRQ==", - "license": "MIT" - }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -3771,6 +3771,12 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, "node_modules/char-regex": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", @@ -3804,12 +3810,6 @@ "dev": true, "license": "MIT" }, - "node_modules/classcat": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", - "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", - "license": "MIT" - }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -3992,6 +3992,20 @@ "devOptional": true, "license": "MIT" }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/d3-color": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", @@ -4097,20 +4111,6 @@ "node": ">=12" } }, - "node_modules/data-urls": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", - "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", - "dev": true, - "license": "MIT", - "dependencies": { - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^14.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -7737,15 +7737,6 @@ "punycode": "^2.1.0" } }, - "node_modules/use-sync-external-store": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", - "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", - "license": "MIT", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, "node_modules/v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", @@ -7761,6 +7752,15 @@ "node": ">=10.12.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", + "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vite": { "version": "7.1.11", "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.11.tgz", -- 2.49.1 From e880e00b6d82b0d98d31ab17011087d1f8394d20 Mon Sep 17 00:00:00 2001 From: JGerla Date: Wed, 22 Oct 2025 12:12:16 +0200 Subject: [PATCH 058/184] style: changed VisProgUI.css to VisProgUI.module.css and updated relevant references ref: N25B-114 --- .../{VisProgUI.css => VisProgUI.module.css} | 16 ++++++++-------- .../visualProgrammingUI/VisProgUI.tsx | 8 ++++---- .../components/DragDropSidebar.tsx | 10 +++++----- .../components/NodeDefinitions.tsx | 10 +++++----- 4 files changed, 22 insertions(+), 22 deletions(-) rename src/pages/VisProgPage/visualProgrammingUI/{VisProgUI.css => VisProgUI.module.css} (92%) diff --git a/src/pages/VisProgPage/visualProgrammingUI/VisProgUI.css b/src/pages/VisProgPage/visualProgrammingUI/VisProgUI.module.css similarity index 92% rename from src/pages/VisProgPage/visualProgrammingUI/VisProgUI.css rename to src/pages/VisProgPage/visualProgrammingUI/VisProgUI.module.css index be3d5a0..7a62538 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/VisProgUI.css +++ b/src/pages/VisProgPage/visualProgrammingUI/VisProgUI.module.css @@ -38,7 +38,7 @@ filter: drop-shadow(0 0 0.75rem black); } -.default-node__norm { +.default-node-norm { padding: 10px 15px; background-color: canvas; border-radius: 5pt; @@ -46,7 +46,7 @@ filter: drop-shadow(0 0 0.25rem forestgreen); } -.default-node__phase { +.default-node-phase { padding: 10px 15px; background-color: canvas; border-radius: 5pt; @@ -54,7 +54,7 @@ filter: drop-shadow(0 0 0.25rem dodgerblue); } -.default-node__start { +.default-node-start { padding: 10px 15px; background-color: canvas; border-radius: 5pt; @@ -62,7 +62,7 @@ filter: drop-shadow(0 0 0.25rem orange); } -.default-node__end { +.default-node-end { padding: 10px 15px; background-color: canvas; border-radius: 5pt; @@ -78,7 +78,7 @@ filter: drop-shadow(0 0 0.75rem black); } -.draggable-node__norm { +.draggable-node-norm { padding: 3px 10px; background-color: canvas; border-radius: 5pt; @@ -86,7 +86,7 @@ filter: drop-shadow(0 0 0.25rem forestgreen); } -.draggable-node__phase { +.draggable-node-phase { padding: 3px 10px; background-color: canvas; border-radius: 5pt; @@ -94,7 +94,7 @@ filter: drop-shadow(0 0 0.25rem dodgerblue); } -.draggable-node__start { +.draggable-node-start { padding: 3px 10px; background-color: canvas; border-radius: 5pt; @@ -102,7 +102,7 @@ filter: drop-shadow(0 0 0.25rem orange); } -.draggable-node__end { +.draggable-node-end { padding: 3px 10px; background-color: canvas; border-radius: 5pt; diff --git a/src/pages/VisProgPage/visualProgrammingUI/VisProgUI.tsx b/src/pages/VisProgPage/visualProgrammingUI/VisProgUI.tsx index 088cc85..c0b0544 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/VisProgUI.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/VisProgUI.tsx @@ -18,7 +18,7 @@ import { import {DndToolbar} from './components/DragDropSidebar.tsx'; import useFlowStore from './VisProgStores.tsx'; import type {FlowState} from './VisProgTypes.tsx'; -import './VisProgUI.css' +import styles from './VisProgUI.module.css' // --| config starting params for flow |-- @@ -79,8 +79,8 @@ const VisProgUI = () => { } = useFlowStore(useShallow(selector)); // instructs the editor to use the corresponding functions from the FlowStore return ( -
-
+
+
{ fitView proOptions={{hideAttribution: true}} > - + {/* contains the drag and drop panel for nodes */} diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx index 1ca9374..14ecc1d 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx @@ -9,6 +9,7 @@ import { useRef, useState } from 'react'; +import styles from "../VisProgUI.module.css" // Is used to make sure each subsequent node gets a unique id, so the ReactFlow implementation // can distinguish between different nodes. @@ -40,7 +41,7 @@ function DraggableNode({className, children, nodeType, onDrop}: DraggableNodePro const draggableRef = useRef(null); const [position, setPosition] = useState({x: 0, y: 0}); - // @ts-ignore + // @ts-expect-error comes from a package and doesn't appear to play nicely with strict typescript typing useDraggable(draggableRef, { position: position, onDrag: ({offsetX, offsetY}) => { @@ -60,8 +61,7 @@ function DraggableNode({className, children, nodeType, onDrop}: DraggableNodePro }); return ( -
+
{children}
); @@ -162,10 +162,10 @@ export function DndToolbar() { gap: '1rem', justifyContent: 'center' }}> - + phase Node - + norm Node
diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/NodeDefinitions.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/NodeDefinitions.tsx index a9fd7b4..6b686ab 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/NodeDefinitions.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/NodeDefinitions.tsx @@ -1,6 +1,6 @@ import {Handle, NodeToolbar, Position, useReactFlow} from '@xyflow/react'; import '@xyflow/react/dist/style.css'; -import '../VisProgUI.css'; +import styles from '../VisProgUI.module.css'; // Contains the datatypes for the data inside our NodeTypes // this has to be improved or adapted to suit our implementation for computing the graph @@ -53,7 +53,7 @@ export const StartNode = ({id, data}: StartNodeProps) => { return ( <> -
+
data test {data.label}
@@ -73,7 +73,7 @@ export const EndNode = ({id, data}: EndNodeProps) => { return ( <> -
+
{data.label}
@@ -93,7 +93,7 @@ export const PhaseNode = ({id, data}: PhaseNodeProps) => { return ( <> -
+
phase {data.number} {data.label}
@@ -115,7 +115,7 @@ export const NormNode = ({id, data}: NormNodeProps) => { return ( <> -
+
Norm {data.label}
-- 2.49.1 From b365a8754e8222a0986687eb7c5ffa2a18d554f2 Mon Sep 17 00:00:00 2001 From: JGerla Date: Wed, 22 Oct 2025 12:31:04 +0200 Subject: [PATCH 059/184] style: moved inline styles to VisProgUI.module.css and used some existing classes to replace simple css ref: N25B-114 --- .../visualProgrammingUI/VisProgUI.module.css | 17 ++++++++++++++++ .../components/DragDropSidebar.tsx | 20 ++----------------- 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/src/pages/VisProgPage/visualProgrammingUI/VisProgUI.module.css b/src/pages/VisProgPage/visualProgrammingUI/VisProgUI.module.css index 7a62538..2b6e836 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/VisProgUI.module.css +++ b/src/pages/VisProgPage/visualProgrammingUI/VisProgUI.module.css @@ -17,6 +17,10 @@ height: 100%; } + + + + .dnd-panel { margin-inline-start: auto; margin-inline-end: auto; @@ -28,6 +32,19 @@ height:7%; } +.inner-dnd-panel { + outline: 2.5pt solid black; + border-radius: 0 0 5pt 5pt; + border-color: dimgrey; + background-color: canvas; + align-items: center; +} + +.dnd-node-container { + background-color: canvas; + justify-content: center; +} + /* Node Styles */ .default-node { diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx index 14ecc1d..71017c9 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx @@ -141,27 +141,11 @@ export function DndToolbar() { ); return ( -
+
You can drag these nodes to the pane to create new nodes.
-
+
phase Node -- 2.49.1 From 34dd48ecb64dcf29362fec420f5c89cea4f18522 Mon Sep 17 00:00:00 2001 From: JGerla Date: Wed, 22 Oct 2025 12:39:35 +0200 Subject: [PATCH 060/184] fix: updated jest.config.js to allow for folders in the test directory ref: N25B-212 --- jest.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jest.config.js b/jest.config.js index ea33067..7f582d1 100644 --- a/jest.config.js +++ b/jest.config.js @@ -7,7 +7,7 @@ export default { '^@/(.*)$': '/src/$1', '\\.(css|scss|sass)$': 'identity-obj-proxy' }, - testMatch: ['/test/*.test.(ts|tsx)'], + testMatch: ['/test/**/*.test.(ts|tsx)'], transform: { '^.+\\.(ts|tsx)$': ['ts-jest', { useESM: true, tsconfig: 'tsconfig.jest.json' }] } -- 2.49.1 From 6a655f62f8347858f072adb99ac20669ee0447f3 Mon Sep 17 00:00:00 2001 From: JGerla Date: Wed, 22 Oct 2025 15:12:29 +0200 Subject: [PATCH 061/184] style: moved all logic from VisProgUI.tsx to VisProg.tsx and the css from VisProgUI.module.css to VisProg.module.css. made code easier to navigate, by removing an unnecessary set of files through combining logic into a mostly empty file that is suitable for hosting said logic. BREAKING: removed VisProgUI.module.css and removed VisProgUI.tsx, logic is now in VisProg.module.css and VisProg.tsx respectively ref: N25B-114 --- src/pages/VisProgPage/VisProg.module.css | 129 ++++++++++++++++- src/pages/VisProgPage/VisProg.tsx | 131 +++++++++++++++++- .../visualProgrammingUI/VisProgUI.module.css | 128 ----------------- .../visualProgrammingUI/VisProgUI.tsx | 126 ----------------- .../components/DragDropSidebar.tsx | 2 +- .../components/NodeDefinitions.tsx | 2 +- 6 files changed, 256 insertions(+), 262 deletions(-) delete mode 100644 src/pages/VisProgPage/visualProgrammingUI/VisProgUI.module.css delete mode 100644 src/pages/VisProgPage/visualProgrammingUI/VisProgUI.tsx diff --git a/src/pages/VisProgPage/VisProg.module.css b/src/pages/VisProgPage/VisProg.module.css index 8526661..f2f90c7 100644 --- a/src/pages/VisProgPage/VisProg.module.css +++ b/src/pages/VisProgPage/VisProg.module.css @@ -1,4 +1,129 @@ -button.reset:hover { - background-color: yellow; +/* editor UI */ + +.outer-editor-container { + margin-inline: auto; + display: flex; + justify-self: center; + padding: 10px; + align-items: center; + width: 80vw; + height: 80vh; +} + +.inner-editor-container { + outline-style: solid; + border-radius: 10pt; + width: 90%; + height: 100%; +} + + + + + +.dnd-panel { + margin-inline-start: auto; + margin-inline-end: auto; + background-color: canvas; + align-content: center; + margin-bottom: 0.5rem; + margin-top:auto; + width: 50%; + height:7%; +} + +.inner-dnd-panel { + outline: 2.5pt solid black; + border-radius: 0 0 5pt 5pt; + border-color: dimgrey; + background-color: canvas; + align-items: center; +} + +.dnd-node-container { + background-color: canvas; + justify-content: center; +} + +/* Node Styles */ + +.default-node { + padding: 10px 15px; + background-color: canvas; + border-radius: 5pt; + outline: black solid 2pt; + filter: drop-shadow(0 0 0.75rem black); +} + +.default-node-norm { + padding: 10px 15px; + background-color: canvas; + border-radius: 5pt; + outline: forestgreen solid 2pt; + filter: drop-shadow(0 0 0.25rem forestgreen); +} + +.default-node-phase { + padding: 10px 15px; + background-color: canvas; + border-radius: 5pt; + outline: dodgerblue solid 2pt; + filter: drop-shadow(0 0 0.25rem dodgerblue); +} + +.default-node-start { + padding: 10px 15px; + background-color: canvas; + border-radius: 5pt; + outline: orange solid 2pt; + filter: drop-shadow(0 0 0.25rem orange); +} + +.default-node-end { + padding: 10px 15px; + background-color: canvas; + border-radius: 5pt; + outline: red solid 2pt; + filter: drop-shadow(0 0 0.25rem red); +} + +.draggable-node { + padding: 3px 10px; + background-color: canvas; + border-radius: 5pt; + outline: black solid 2pt; + filter: drop-shadow(0 0 0.75rem black); +} + +.draggable-node-norm { + padding: 3px 10px; + background-color: canvas; + border-radius: 5pt; + outline: forestgreen solid 2pt; + filter: drop-shadow(0 0 0.25rem forestgreen); +} + +.draggable-node-phase { + padding: 3px 10px; + background-color: canvas; + border-radius: 5pt; + outline: dodgerblue solid 2pt; + filter: drop-shadow(0 0 0.25rem dodgerblue); +} + +.draggable-node-start { + padding: 3px 10px; + background-color: canvas; + border-radius: 5pt; + outline: orange solid 2pt; + filter: drop-shadow(0 0 0.25rem orange); +} + +.draggable-node-end { + padding: 3px 10px; + background-color: canvas; + border-radius: 5pt; + outline: red solid 2pt; + filter: drop-shadow(0 0 0.25rem red); } diff --git a/src/pages/VisProgPage/VisProg.tsx b/src/pages/VisProgPage/VisProg.tsx index 785c564..4b8944c 100644 --- a/src/pages/VisProgPage/VisProg.tsx +++ b/src/pages/VisProgPage/VisProg.tsx @@ -1,14 +1,137 @@ -import VisProgUI from "./visualProgrammingUI/VisProgUI.tsx"; +import { + Background, + Controls, + Panel, + ReactFlow, + ReactFlowProvider, + MarkerType, +} from '@xyflow/react'; +import '@xyflow/react/dist/style.css'; +import {useShallow} from 'zustand/react/shallow'; + +import { + StartNode, + EndNode, + PhaseNode, + NormNode +} from './visualProgrammingUI/components/NodeDefinitions.tsx'; +import {DndToolbar} from './visualProgrammingUI/components/DragDropSidebar.tsx'; +import useFlowStore from './visualProgrammingUI/VisProgStores.tsx'; +import type {FlowState} from './visualProgrammingUI/VisProgTypes.tsx'; +import styles from './VisProg.module.css' + +// --| config starting params for flow |-- + +/** + * contains the types of all nodes that are available in the editor + */ +const NODE_TYPES = { + start: StartNode, + end: EndNode, + phase: PhaseNode, + norm: NormNode +}; + +/** + * defines how the default edge looks inside the editor + */ +const DEFAULT_EDGE_OPTIONS = { + type: 'default', + markerEnd: { + type: MarkerType.ArrowClosed, + color: '#505050', + }, +}; -//this is your css file where you can style your buttons and such -//you can still use css parts from App.css, but also overwrite them +/** + * defines what functions in the FlowState store map to which names, + * @param state + */ +const selector = (state: FlowState) => ({ + nodes: state.nodes, + edges: state.edges, + onNodesChange: state.onNodesChange, + onEdgesChange: state.onEdgesChange, + onConnect: state.onConnect, + onReconnectStart: state.onReconnectStart, + onReconnectEnd: state.onReconnectEnd, + onReconnect: state.onReconnect +}); + +// --| define ReactFlow editor |-- + +/** + * Defines the ReactFlow visual programming editor component + * any implementations of editor logic should be encapsulated where possible + * so the Component definition stays as readable as possible + * @constructor + */ +const VisProgUI = () => { + const { + nodes, edges, + onNodesChange, + onEdgesChange, + onConnect, + onReconnect, + onReconnectStart, + onReconnectEnd + } = useFlowStore(useShallow(selector)); // instructs the editor to use the corresponding functions from the FlowStore + + return ( +
+
+ + + {/* contains the drag and drop panel for nodes */} + + + + +
+
+ ); +}; +/** + * Places the VisProgUI component inside a ReactFlowProvider + * + * Wrapping the editor component inside a ReactFlowProvider + * allows us to access and interact with the components inside the editor, outside the editor definition, + * thus facilitating the addition of node specific functions inside their node definitions + */ +function VisualProgrammingUI() { + return ( + + + + ); +} + +/** + * houses the entire page, so also UI elements + * that are not a part of the Visual Programming UI + * @constructor + */ function VisProgPage() { return ( <> - + ) } diff --git a/src/pages/VisProgPage/visualProgrammingUI/VisProgUI.module.css b/src/pages/VisProgPage/visualProgrammingUI/VisProgUI.module.css deleted file mode 100644 index 2b6e836..0000000 --- a/src/pages/VisProgPage/visualProgrammingUI/VisProgUI.module.css +++ /dev/null @@ -1,128 +0,0 @@ -/* editor UI */ - -.outer-editor-container { - margin-inline: auto; - display: flex; - justify-self: center; - padding: 10px; - align-items: center; - width: 80vw; - height: 80vh; -} - -.inner-editor-container { - outline-style: solid; - border-radius: 10pt; - width: 90%; - height: 100%; -} - - - - - -.dnd-panel { - margin-inline-start: auto; - margin-inline-end: auto; - background-color: canvas; - align-content: center; - margin-bottom: 0.5rem; - margin-top:auto; - width: 50%; - height:7%; -} - -.inner-dnd-panel { - outline: 2.5pt solid black; - border-radius: 0 0 5pt 5pt; - border-color: dimgrey; - background-color: canvas; - align-items: center; -} - -.dnd-node-container { - background-color: canvas; - justify-content: center; -} - -/* Node Styles */ - -.default-node { - padding: 10px 15px; - background-color: canvas; - border-radius: 5pt; - outline: black solid 2pt; - filter: drop-shadow(0 0 0.75rem black); -} - -.default-node-norm { - padding: 10px 15px; - background-color: canvas; - border-radius: 5pt; - outline: forestgreen solid 2pt; - filter: drop-shadow(0 0 0.25rem forestgreen); -} - -.default-node-phase { - padding: 10px 15px; - background-color: canvas; - border-radius: 5pt; - outline: dodgerblue solid 2pt; - filter: drop-shadow(0 0 0.25rem dodgerblue); -} - -.default-node-start { - padding: 10px 15px; - background-color: canvas; - border-radius: 5pt; - outline: orange solid 2pt; - filter: drop-shadow(0 0 0.25rem orange); -} - -.default-node-end { - padding: 10px 15px; - background-color: canvas; - border-radius: 5pt; - outline: red solid 2pt; - filter: drop-shadow(0 0 0.25rem red); -} - -.draggable-node { - padding: 3px 10px; - background-color: canvas; - border-radius: 5pt; - outline: black solid 2pt; - filter: drop-shadow(0 0 0.75rem black); -} - -.draggable-node-norm { - padding: 3px 10px; - background-color: canvas; - border-radius: 5pt; - outline: forestgreen solid 2pt; - filter: drop-shadow(0 0 0.25rem forestgreen); -} - -.draggable-node-phase { - padding: 3px 10px; - background-color: canvas; - border-radius: 5pt; - outline: dodgerblue solid 2pt; - filter: drop-shadow(0 0 0.25rem dodgerblue); -} - -.draggable-node-start { - padding: 3px 10px; - background-color: canvas; - border-radius: 5pt; - outline: orange solid 2pt; - filter: drop-shadow(0 0 0.25rem orange); -} - -.draggable-node-end { - padding: 3px 10px; - background-color: canvas; - border-radius: 5pt; - outline: red solid 2pt; - filter: drop-shadow(0 0 0.25rem red); -} \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/VisProgUI.tsx b/src/pages/VisProgPage/visualProgrammingUI/VisProgUI.tsx deleted file mode 100644 index c0b0544..0000000 --- a/src/pages/VisProgPage/visualProgrammingUI/VisProgUI.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import { - Background, - Controls, - Panel, - ReactFlow, - ReactFlowProvider, - MarkerType, -} from '@xyflow/react'; -import '@xyflow/react/dist/style.css'; -import {useShallow} from 'zustand/react/shallow'; - -import { - StartNode, - EndNode, - PhaseNode, - NormNode -} from './components/NodeDefinitions.tsx'; -import {DndToolbar} from './components/DragDropSidebar.tsx'; -import useFlowStore from './VisProgStores.tsx'; -import type {FlowState} from './VisProgTypes.tsx'; -import styles from './VisProgUI.module.css' - -// --| config starting params for flow |-- - -/** - * contains the types of all nodes that are available in the editor - */ -const NODE_TYPES = { - start: StartNode, - end: EndNode, - phase: PhaseNode, - norm: NormNode -}; - -/** - * defines how the default edge looks inside the editor - */ -const DEFAULT_EDGE_OPTIONS = { - type: 'default', - markerEnd: { - type: MarkerType.ArrowClosed, - color: '#505050', - }, -}; - - -/** - * defines what functions in the FlowState store map to which names, - * @param state - */ -const selector = (state: FlowState) => ({ - nodes: state.nodes, - edges: state.edges, - onNodesChange: state.onNodesChange, - onEdgesChange: state.onEdgesChange, - onConnect: state.onConnect, - onReconnectStart: state.onReconnectStart, - onReconnectEnd: state.onReconnectEnd, - onReconnect: state.onReconnect -}); - -// --| define ReactFlow editor |-- - -/** - * Defines the ReactFlow visual programming editor component - * any implementations of editor logic should be encapsulated where possible - * so the Component definition stays as readable as possible - * @constructor - */ -const VisProgUI = () => { - const { - nodes, edges, - onNodesChange, - onEdgesChange, - onConnect, - onReconnect, - onReconnectStart, - onReconnectEnd - } = useFlowStore(useShallow(selector)); // instructs the editor to use the corresponding functions from the FlowStore - - return ( -
-
- - - {/* contains the drag and drop panel for nodes */} - - - - -
-
- ); -}; - - -/** - * Places the VisProgUI component inside a ReactFlowProvider - * - * Wrapping the editor component inside a ReactFlowProvider - * allows us to access and interact with the components inside the editor, outside the editor definition, - * thus facilitating the addition of node specific functions inside their node definitions - */ -function VisualProgrammingUI() { - return ( - - - - ); -} - -export default VisualProgrammingUI; \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx index 71017c9..693fb53 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx @@ -9,7 +9,7 @@ import { useRef, useState } from 'react'; -import styles from "../VisProgUI.module.css" +import styles from "../../VisProg.module.css" // Is used to make sure each subsequent node gets a unique id, so the ReactFlow implementation // can distinguish between different nodes. diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/NodeDefinitions.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/NodeDefinitions.tsx index 6b686ab..f30cfd9 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/NodeDefinitions.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/NodeDefinitions.tsx @@ -1,6 +1,6 @@ import {Handle, NodeToolbar, Position, useReactFlow} from '@xyflow/react'; import '@xyflow/react/dist/style.css'; -import styles from '../VisProgUI.module.css'; +import styles from '../../VisProg.module.css'; // Contains the datatypes for the data inside our NodeTypes // this has to be improved or adapted to suit our implementation for computing the graph -- 2.49.1 From 3bd1aa99e3f6f15c0e7bb5c4cf04242ff756e725 Mon Sep 17 00:00:00 2001 From: JGerla Date: Wed, 22 Oct 2025 15:43:47 +0200 Subject: [PATCH 062/184] chore: added setupFlowTests.ts for ReactFlow specific testing added Setup config for mocking reactflow based on the provided information in ReactFlow documentation ref: N25B-114 --- jest.config.js | 2 +- test/setupFlowTests.ts | 70 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 test/setupFlowTests.ts diff --git a/jest.config.js b/jest.config.js index 7f582d1..ba73e36 100644 --- a/jest.config.js +++ b/jest.config.js @@ -2,7 +2,7 @@ export default { preset: 'ts-jest/presets/default-esm', testEnvironment: 'jsdom', extensionsToTreatAsEsm: ['.ts', '.tsx'], - setupFilesAfterEnv: ['/test/setupTests.ts'], + setupFilesAfterEnv: ['/test/setupTests.ts', '/test/setupFlowTests.ts' ], moduleNameMapper: { '^@/(.*)$': '/src/$1', '\\.(css|scss|sass)$': 'identity-obj-proxy' diff --git a/test/setupFlowTests.ts b/test/setupFlowTests.ts new file mode 100644 index 0000000..973460a --- /dev/null +++ b/test/setupFlowTests.ts @@ -0,0 +1,70 @@ +import '@testing-library/jest-dom'; +import { cleanup } from '@testing-library/react'; +import useFlowStore from '../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx'; + + +// To make sure that the tests are working, it's important that you are using +// this implementation of ResizeObserver and DOMMatrixReadOnly +class ResizeObserver { + callback: globalThis.ResizeObserverCallback; + + constructor(callback: globalThis.ResizeObserverCallback) { + this.callback = callback; + } + + observe(target: Element) { + this.callback([{ target } as globalThis.ResizeObserverEntry], this); + } + + unobserve() {} + + disconnect() {} +} + +class DOMMatrixReadOnly { + m22: number; + constructor(transform: string) { + const scale = transform?.match(/scale\(([1-9.])\)/)?.[1]; + this.m22 = scale !== undefined ? +scale : 1; + } +} + +// Only run the shim once when requested +let init = false; + +export const mockReactFlow = () => { + if (init) return; + init = true; + + globalThis.ResizeObserver = ResizeObserver; + + // @ts-expect-error included in advised setup code provided in ReactFlow documentation + global.DOMMatrixReadOnly = DOMMatrixReadOnly; + + Object.defineProperties(globalThis.HTMLElement.prototype, { + offsetHeight: { + get() { + return parseFloat(this.style.height) || 1; + }, + }, + offsetWidth: { + get() { + return parseFloat(this.style.width) || 1; + }, + }, + }); + + // @ts-expect-error included in advised setup code provided in ReactFlow documentation + (globalThis.SVGElement as never).prototype.getBBox = () => ({ + x: 0, + y: 0, + width: 0, + height: 0, + }); +}; + +afterEach(() => { + cleanup(); + useFlowStore.setState({ nodes: [], edges: [] }); +}); + -- 2.49.1 From 8513be5a5631a19461e455ce1974c8db94432027 Mon Sep 17 00:00:00 2001 From: JGerla Date: Wed, 22 Oct 2025 16:30:24 +0200 Subject: [PATCH 063/184] test: added test for onConnect event ref: N25B-114 --- test/pages/visProgPage/VisProg.test.tsx | 30 +++++++++++++++++++++++++ test/setupFlowTests.ts | 7 +++--- 2 files changed, 33 insertions(+), 4 deletions(-) create mode 100644 test/pages/visProgPage/VisProg.test.tsx diff --git a/test/pages/visProgPage/VisProg.test.tsx b/test/pages/visProgPage/VisProg.test.tsx new file mode 100644 index 0000000..96cb342 --- /dev/null +++ b/test/pages/visProgPage/VisProg.test.tsx @@ -0,0 +1,30 @@ +import { act } from '@testing-library/react'; +import useFlowStore from '../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx'; +import { mockReactFlow } from '../../setupFlowTests.ts'; + +beforeAll(() => { + mockReactFlow(); +}); + +describe('FlowCanvas onConnect', () => { + + test('adds an edge when onConnect is triggered', () => { + const { onConnect} = useFlowStore.getState(); + + act(() => { + onConnect({ + source: 'A', + target: 'B', + sourceHandle: null, + targetHandle: null, + }); + }); + + const updatedEdges = useFlowStore.getState().edges; + expect(updatedEdges).toHaveLength(1); + expect(updatedEdges[0]).toMatchObject({ + source: 'A', + target: 'B', + }); + }); +}); \ No newline at end of file diff --git a/test/setupFlowTests.ts b/test/setupFlowTests.ts index 973460a..4e5a28c 100644 --- a/test/setupFlowTests.ts +++ b/test/setupFlowTests.ts @@ -63,8 +63,7 @@ export const mockReactFlow = () => { }); }; -afterEach(() => { - cleanup(); - useFlowStore.setState({ nodes: [], edges: [] }); -}); +beforeEach(() => { useFlowStore.setState({ nodes: [], edges: [] }); }) + +afterEach(() => { cleanup(); }); -- 2.49.1 From b64d4fbd01a3c10d80711338ae67cd3ae7f743c6 Mon Sep 17 00:00:00 2001 From: JGerla Date: Sat, 25 Oct 2025 13:54:18 +0200 Subject: [PATCH 064/184] chore: added test coverage collection updated the jest configuration to generate a coverage report on the entire project when tests are run. this coverage report has been added to the the gitignore file as it is not relevant to store it in the online repository. ref: N25B-114 --- .gitignore | 3 +++ jest.config.js | 4 +++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index a547bf3..4147656 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,6 @@ dist-ssr *.njsproj *.sln *.sw? + +# Coverage report +coverage \ No newline at end of file diff --git a/jest.config.js b/jest.config.js index ba73e36..819a05d 100644 --- a/jest.config.js +++ b/jest.config.js @@ -10,5 +10,7 @@ export default { testMatch: ['/test/**/*.test.(ts|tsx)'], transform: { '^.+\\.(ts|tsx)$': ['ts-jest', { useESM: true, tsconfig: 'tsconfig.jest.json' }] - } + }, + collectCoverage:true, + collectCoverageFrom: ['/src/**/*.{ts,tsx,js,jsx}'], }; \ No newline at end of file -- 2.49.1 From ea79de5ee54ad141967bdb65dde5b329d0862597 Mon Sep 17 00:00:00 2001 From: JGerla Date: Sat, 25 Oct 2025 13:57:37 +0200 Subject: [PATCH 065/184] style: restructured and renamed VisProg.test.tsx to VisProgStores.test.tsx renamed as the functionality being tested is contained within the VisProgStores.tsx file and thus the filename for the tests should reflect that relation, so order is preserved in the project. also added a second describe layer to group the tests for all FlowStore functions together for organisational purposes. ref: N25B-114 --- test/pages/visProgPage/VisProg.test.tsx | 30 ------------------ .../VisProgStores.test.tsx | 31 +++++++++++++++++++ 2 files changed, 31 insertions(+), 30 deletions(-) delete mode 100644 test/pages/visProgPage/VisProg.test.tsx create mode 100644 test/pages/visProgPage/visualProgrammingUI/VisProgStores.test.tsx diff --git a/test/pages/visProgPage/VisProg.test.tsx b/test/pages/visProgPage/VisProg.test.tsx deleted file mode 100644 index 96cb342..0000000 --- a/test/pages/visProgPage/VisProg.test.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { act } from '@testing-library/react'; -import useFlowStore from '../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx'; -import { mockReactFlow } from '../../setupFlowTests.ts'; - -beforeAll(() => { - mockReactFlow(); -}); - -describe('FlowCanvas onConnect', () => { - - test('adds an edge when onConnect is triggered', () => { - const { onConnect} = useFlowStore.getState(); - - act(() => { - onConnect({ - source: 'A', - target: 'B', - sourceHandle: null, - targetHandle: null, - }); - }); - - const updatedEdges = useFlowStore.getState().edges; - expect(updatedEdges).toHaveLength(1); - expect(updatedEdges[0]).toMatchObject({ - source: 'A', - target: 'B', - }); - }); -}); \ No newline at end of file diff --git a/test/pages/visProgPage/visualProgrammingUI/VisProgStores.test.tsx b/test/pages/visProgPage/visualProgrammingUI/VisProgStores.test.tsx new file mode 100644 index 0000000..66c0343 --- /dev/null +++ b/test/pages/visProgPage/visualProgrammingUI/VisProgStores.test.tsx @@ -0,0 +1,31 @@ +import { act } from '@testing-library/react'; +import useFlowStore from '../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx'; +import { mockReactFlow } from '../../../setupFlowTests.ts'; + +beforeAll(() => { + mockReactFlow(); +}); + +describe('FlowStore Functionality', () => { + describe('ReactFlow onConnect', () => { + test('adds an edge when onConnect is triggered', () => { + const { onConnect} = useFlowStore.getState(); + + act(() => { + onConnect({ + source: 'A', + target: 'B', + sourceHandle: null, + targetHandle: null, + }); + }); + + const updatedEdges = useFlowStore.getState().edges; + expect(updatedEdges).toHaveLength(1); + expect(updatedEdges[0]).toMatchObject({ + source: 'A', + target: 'B', + }); + }); + }); +}) -- 2.49.1 From 023e1d2b8b4a06f1afb9b0f0957232cad3127e41 Mon Sep 17 00:00:00 2001 From: JGerla Date: Sat, 25 Oct 2025 14:59:34 +0200 Subject: [PATCH 066/184] test: Added tests for setNodes and setEdges. ref: N25B-114 --- .../VisProgStores.test.tsx | 83 ++++++++++++++++++- test/setupFlowTests.ts | 2 +- 2 files changed, 80 insertions(+), 5 deletions(-) diff --git a/test/pages/visProgPage/visualProgrammingUI/VisProgStores.test.tsx b/test/pages/visProgPage/visualProgrammingUI/VisProgStores.test.tsx index 66c0343..a9ae892 100644 --- a/test/pages/visProgPage/visualProgrammingUI/VisProgStores.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/VisProgStores.test.tsx @@ -1,4 +1,4 @@ -import { act } from '@testing-library/react'; +import {act} from '@testing-library/react'; import useFlowStore from '../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx'; import { mockReactFlow } from '../../../setupFlowTests.ts'; @@ -7,10 +7,19 @@ beforeAll(() => { }); describe('FlowStore Functionality', () => { + describe('Node changes', () => { + // currently just using a single function from the ReactFlow library, + // so testing would mean we are testing already tested behavior. + // if implementation gets modified tests should be added for custom behavior + }); + describe('Edge changes', () => { + // currently just using a single function from the ReactFlow library, + // so testing would mean we are testing already tested behavior. + // if implementation gets modified tests should be added for custom behavior + }) describe('ReactFlow onConnect', () => { test('adds an edge when onConnect is triggered', () => { - const { onConnect} = useFlowStore.getState(); - + const {onConnect} = useFlowStore.getState(); act(() => { onConnect({ source: 'A', @@ -28,4 +37,70 @@ describe('FlowStore Functionality', () => { }); }); }); -}) + describe('ReactFlow onReconnect', () => { + }); + describe('ReactFlow onReconnectStart', () => { + }); + describe('ReactFlow onReconnectEnd', () => { + }); + describe('ReactFlow setNodes', () => { + test('sets nodes to the provided list of nodes', () => { + const {setNodes} = useFlowStore.getState(); + + act(() => { + setNodes([ + { + id: 'start', + type: 'start', + position: {x: 0, y: 0}, + data: {label: 'start'} + }, + { + id: 'end', + type: 'end', + position: {x: 0, y: 300}, + data: {label: 'End'} + } + ]); + }); + + const updatedNodes = useFlowStore.getState().nodes; + expect(updatedNodes).toHaveLength(2); + expect(updatedNodes[0]).toMatchObject({ + id: 'start', + type: 'start', + position: {x: 0, y: 0}, + data: {label: 'start'} + }); + expect(updatedNodes[1]).toMatchObject({ + id: 'end', + type: 'end', + position: {x: 0, y: 300}, + data: {label: 'End'} + }); + }); + }); + describe('ReactFlow setEdges', () => { + test('sets edges to the provided list of edges', () => { + const {setEdges} = useFlowStore.getState(); + + act(() => { + setEdges([ + { + id: 'start-end', + source: 'start', + target: 'end' + } + ]); + }); + + const updatedEdges = useFlowStore.getState().edges; + expect(updatedEdges).toHaveLength(1); + expect(updatedEdges[0]).toMatchObject({ + id: 'start-end', + source: 'start', + target: 'end' + }); + }); + }); +}); diff --git a/test/setupFlowTests.ts b/test/setupFlowTests.ts index 4e5a28c..b625c03 100644 --- a/test/setupFlowTests.ts +++ b/test/setupFlowTests.ts @@ -63,7 +63,7 @@ export const mockReactFlow = () => { }); }; -beforeEach(() => { useFlowStore.setState({ nodes: [], edges: [] }); }) +beforeEach(() => { useFlowStore.setState({ nodes: [], edges: [] }); }); afterEach(() => { cleanup(); }); -- 2.49.1 From 42357217e583f402ba6a3659dbbd370ac0c1f849 Mon Sep 17 00:00:00 2001 From: JGerla Date: Sat, 25 Oct 2025 15:17:14 +0200 Subject: [PATCH 067/184] test: Added test for onReconnect ref: N25B-114 --- .../VisProgStores.test.tsx | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/test/pages/visProgPage/visualProgrammingUI/VisProgStores.test.tsx b/test/pages/visProgPage/visualProgrammingUI/VisProgStores.test.tsx index a9ae892..be59640 100644 --- a/test/pages/visProgPage/visualProgrammingUI/VisProgStores.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/VisProgStores.test.tsx @@ -20,6 +20,7 @@ describe('FlowStore Functionality', () => { describe('ReactFlow onConnect', () => { test('adds an edge when onConnect is triggered', () => { const {onConnect} = useFlowStore.getState(); + act(() => { onConnect({ source: 'A', @@ -38,6 +39,34 @@ describe('FlowStore Functionality', () => { }); }); describe('ReactFlow onReconnect', () => { + test('reconnects an existing edge when onReconnect is triggered', () => { + const {onReconnect} = useFlowStore.getState(); + const oldEdge = { + id: 'xy-edge__A-B', + source: 'A', + target: 'B' + }; + const newConnection = { + source: 'A', + target: 'C', + sourceHandle: null, + targetHandle: null, + }; + act(() => { + useFlowStore.setState({ + edges: [oldEdge] + }); + onReconnect(oldEdge, newConnection); + }); + + const updatedEdges = useFlowStore.getState().edges; + expect(updatedEdges).toHaveLength(1); + expect(updatedEdges[0]).toMatchObject({ + id: 'xy-edge__A-C', + source: 'A', + target: 'C', + }); + }); }); describe('ReactFlow onReconnectStart', () => { }); -- 2.49.1 From 2e2bd587d09c90b71ed6e544758018a3ec8dc1c0 Mon Sep 17 00:00:00 2001 From: JGerla Date: Sat, 25 Oct 2025 15:27:50 +0200 Subject: [PATCH 068/184] test: Added test for onReconnectStart ref: N25B-114 --- .../visualProgrammingUI/VisProgStores.test.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/test/pages/visProgPage/visualProgrammingUI/VisProgStores.test.tsx b/test/pages/visProgPage/visualProgrammingUI/VisProgStores.test.tsx index be59640..de71391 100644 --- a/test/pages/visProgPage/visualProgrammingUI/VisProgStores.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/VisProgStores.test.tsx @@ -69,6 +69,17 @@ describe('FlowStore Functionality', () => { }); }); describe('ReactFlow onReconnectStart', () => { + test('does correct setup for edge reconnection sequences', () => { + const {onReconnectStart} = useFlowStore.getState(); + + act(() => { + useFlowStore.setState({edgeReconnectSuccessful: true}); + onReconnectStart(); + }); + + const updatedState = useFlowStore.getState().edgeReconnectSuccessful; + expect(updatedState).toEqual(false); + }); }); describe('ReactFlow onReconnectEnd', () => { }); -- 2.49.1 From 1f2b57fbcdd0746da1050f03fe37a7598eccac50 Mon Sep 17 00:00:00 2001 From: JGerla Date: Sat, 25 Oct 2025 15:34:49 +0200 Subject: [PATCH 069/184] chore: updated setupFlowTests.ts to properly handle resetting of the flowState for each test ref: N25B-114 --- test/setupFlowTests.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/test/setupFlowTests.ts b/test/setupFlowTests.ts index b625c03..99920df 100644 --- a/test/setupFlowTests.ts +++ b/test/setupFlowTests.ts @@ -63,7 +63,20 @@ export const mockReactFlow = () => { }); }; -beforeEach(() => { useFlowStore.setState({ nodes: [], edges: [] }); }); +beforeAll(() => { + useFlowStore.setState({ + nodes: [], + edges: [], + edgeReconnectSuccessful: true + }); +}); -afterEach(() => { cleanup(); }); +afterEach(() => { + cleanup(); + useFlowStore.setState({ + nodes: [], + edges: [], + edgeReconnectSuccessful: true + }); +}); -- 2.49.1 From 48dabb86e0df80e2118fe6db82e8efd632f4e913 Mon Sep 17 00:00:00 2001 From: JGerla Date: Sat, 25 Oct 2025 15:37:24 +0200 Subject: [PATCH 070/184] test: updated the onReconnectStart test to reflect updated setupFlowTest.ts ref: N25B-114 --- .../pages/visProgPage/visualProgrammingUI/VisProgStores.test.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/test/pages/visProgPage/visualProgrammingUI/VisProgStores.test.tsx b/test/pages/visProgPage/visualProgrammingUI/VisProgStores.test.tsx index de71391..3fe5b5c 100644 --- a/test/pages/visProgPage/visualProgrammingUI/VisProgStores.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/VisProgStores.test.tsx @@ -73,7 +73,6 @@ describe('FlowStore Functionality', () => { const {onReconnectStart} = useFlowStore.getState(); act(() => { - useFlowStore.setState({edgeReconnectSuccessful: true}); onReconnectStart(); }); -- 2.49.1 From c8484d28e5a357fdfaf3a2b42e193973a8b83527 Mon Sep 17 00:00:00 2001 From: JGerla Date: Sat, 25 Oct 2025 16:03:57 +0200 Subject: [PATCH 071/184] test: added tests for onReconnectEnd ref: N25B-114 --- .../VisProgStores.test.tsx | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/test/pages/visProgPage/visualProgrammingUI/VisProgStores.test.tsx b/test/pages/visProgPage/visualProgrammingUI/VisProgStores.test.tsx index 3fe5b5c..b6eb9d3 100644 --- a/test/pages/visProgPage/visualProgrammingUI/VisProgStores.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/VisProgStores.test.tsx @@ -81,6 +81,49 @@ describe('FlowStore Functionality', () => { }); }); describe('ReactFlow onReconnectEnd', () => { + // prepares the state to have an edge in the edge array + beforeEach(() => { + useFlowStore.setState({edges: [ + { + id: 'xy-edge__A-B', + source: 'A', + target: 'B' + } + ]} + ); + }); + + test('successfully removes edge if no successful reconnect occurred', () => { + const {onReconnectEnd} = useFlowStore.getState(); + useFlowStore.setState({edgeReconnectSuccessful: false}); + + act(() => { + onReconnectEnd(null, {id: 'xy-edge__A-B'}); + }); + + const updatedState = useFlowStore.getState(); + expect(updatedState.edgeReconnectSuccessful).toBe(true); + expect(updatedState.edges).toHaveLength(0); + }); + + test('does not remove reconnecting edge if successful reconnect occurred', () => { + const {onReconnectEnd} = useFlowStore.getState(); + + act(() => { + onReconnectEnd(null, {id: 'xy-edge__A-B'}); + }); + + const updatedState = useFlowStore.getState(); + expect(updatedState.edgeReconnectSuccessful).toBe(true); + expect(updatedState.edges).toHaveLength(1); + expect(updatedState.edges).toMatchObject([ + { + id: 'xy-edge__A-B', + source: 'A', + target: 'B' + }] + ); + }); }); describe('ReactFlow setNodes', () => { test('sets nodes to the provided list of nodes', () => { -- 2.49.1 From 8c698d1f8e0e1b65eb5d486d94a5944dbb3a3ca2 Mon Sep 17 00:00:00 2001 From: JGerla Date: Sat, 25 Oct 2025 16:58:23 +0200 Subject: [PATCH 072/184] refactor: moved delete logic out of the toolbar declaration and into the FlowStore. BREAKING: changed type of FlowState ref: N25B-114 --- .../visualProgrammingUI/VisProgStores.tsx | 6 ++++++ .../visualProgrammingUI/VisProgTypes.tsx | 1 + .../components/NodeDefinitions.tsx | 14 +++++++------- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx index 3eaaa6e..10d0142 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx @@ -93,6 +93,12 @@ const useFlowStore = create((set, get) => ({ edgeReconnectSuccessful: true }); }, + deleteNode: (nodeId: string) => { + set({ + nodes: get().nodes.filter((n) => n.id !== nodeId), + edges: get().edges.filter((e) => e.source !== nodeId && e.target !== nodeId) + }); + }, setNodes: (nodes) => { set({nodes}); }, diff --git a/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx b/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx index 005a401..f5ede86 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx @@ -28,6 +28,7 @@ export type FlowState = { onReconnect: OnReconnect; onReconnectStart: () => void; onReconnectEnd: (_: unknown, edge: { id: string }) => void; + deleteNode: (nodeId: string) => void; setNodes: (nodes: AppNode[]) => void; setEdges: (edges: Edge[]) => void; }; \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/NodeDefinitions.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/NodeDefinitions.tsx index f30cfd9..63765b5 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/NodeDefinitions.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/NodeDefinitions.tsx @@ -1,6 +1,7 @@ -import {Handle, NodeToolbar, Position, useReactFlow} from '@xyflow/react'; +import {Handle, NodeToolbar, Position} from '@xyflow/react'; import '@xyflow/react/dist/style.css'; import styles from '../../VisProg.module.css'; +import useFlowStore from "../VisProgStores.tsx"; // Contains the datatypes for the data inside our NodeTypes // this has to be improved or adapted to suit our implementation for computing the graph @@ -27,15 +28,14 @@ type ToolbarProps = { }; export function Toolbar({nodeId, allowDelete}: ToolbarProps) { - const {setNodes, setEdges} = useReactFlow(); + const {deleteNode} = useFlowStore(); - const handleDelete = () => { - setNodes((nds) => nds.filter((n) => n.id !== nodeId)); - setEdges((eds) => eds.filter((e) => e.source !== nodeId && e.target !== nodeId)); - }; + const deleteParentNode = ()=> { + deleteNode(nodeId); + } return ( - + ); } -- 2.49.1 From 803867416793e59ad8989c49aefd7688768d72d6 Mon Sep 17 00:00:00 2001 From: JGerla Date: Sat, 25 Oct 2025 17:13:42 +0200 Subject: [PATCH 073/184] test: added tests for deleteNode function ref: N25B-114 --- .../VisProgStores.test.tsx | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/test/pages/visProgPage/visualProgrammingUI/VisProgStores.test.tsx b/test/pages/visProgPage/visualProgrammingUI/VisProgStores.test.tsx index b6eb9d3..9b3ab80 100644 --- a/test/pages/visProgPage/visualProgrammingUI/VisProgStores.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/VisProgStores.test.tsx @@ -125,6 +125,42 @@ describe('FlowStore Functionality', () => { ); }); }); + describe('ReactFlow deleteNode', () => { + // test deleting A and B, so we make sure the connecting edge gets deleted regardless of + test.each([['A','B'],['B','A']])('deletes a node and its connected edges', (nodeId, undeletedNodeId) => { + const {deleteNode} = useFlowStore.getState(); + useFlowStore.setState({ + nodes: [ + { + id: 'A', + type: 'default', + position: {x: 0, y: 0}, + data: {label: 'A'} + }, + { + id: 'B', + type: 'default', + position: {x: 0, y: 300}, + data: {label: 'A'} + }], + edges: [ + { + id: 'xy-edge__A-B', + source: 'A', + target: 'B' + }] + }); + + act(()=> { + deleteNode(nodeId); + }); + + const updatedState = useFlowStore.getState(); + expect(updatedState.edges).toHaveLength(0); + expect(updatedState.nodes).toHaveLength(1); + expect(updatedState.nodes[0].id).toBe(undeletedNodeId); + }); + }); describe('ReactFlow setNodes', () => { test('sets nodes to the provided list of nodes', () => { const {setNodes} = useFlowStore.getState(); -- 2.49.1 From e1d131e64231b10ef0e2f91348919e0ab58fdbf1 Mon Sep 17 00:00:00 2001 From: JGerla Date: Sat, 25 Oct 2025 18:12:57 +0200 Subject: [PATCH 074/184] feat: updated the phase node creation to better assign ids to new phase and norm nodes. ref: N25B-114 --- .../components/DragDropSidebar.tsx | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx index 693fb53..ee79037 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx @@ -10,6 +10,7 @@ import { useState } from 'react'; import styles from "../../VisProg.module.css" +import useFlowStore from "../VisProgStores.tsx"; // Is used to make sure each subsequent node gets a unique id, so the ReactFlow implementation // can distinguish between different nodes. @@ -73,7 +74,8 @@ function DraggableNode({className, children, nodeType, onDrop}: DraggableNodePro * @constructor */ export function DndToolbar() { - const {setNodes, screenToFlowPosition} = useReactFlow(); + const {screenToFlowPosition} = useReactFlow(); + const {setNodes} = useReactFlow(); /** * handleNodeDrop implements the default onDrop behavior @@ -96,12 +98,17 @@ export function DndToolbar() { const newNode = () => { switch (nodeType) { case "phase": + { + const nds = useFlowStore.getState().nodes; + const phaseNumber = nds.filter((node) => node.type === 'phase').length; return { - id: getId(), + id: `phase-${phaseNumber}`, type: nodeType, position, - data: {label: `"new"`, number: (-1)}, + data: {label: `"new"`, number: phaseNumber}, }; + } + case "start": return { id: getId(), @@ -117,12 +124,16 @@ export function DndToolbar() { data: {label: `new end node`}, }; case "norm": + { + const nds = useFlowStore.getState().nodes; + const normNumber = nds.filter((node) => node.type === 'norm').length; return { - id: getId(), + id: `norm-${normNumber}`, type: nodeType, position, data: {label: `new norm node`}, }; + } default: { return { id: getId(), -- 2.49.1 From b7cd925c2c69a095ffc04f279260a908f08d7d5b Mon Sep 17 00:00:00 2001 From: JGerla Date: Sat, 25 Oct 2025 18:29:53 +0200 Subject: [PATCH 075/184] refactor: moved addNode function outside the handleNode definition Made sure to minimize responsibilities per function, by taking the addNode logic and moving it into its own function. ref: N25B-114 --- .../components/DragDropSidebar.tsx | 110 +++++++++--------- 1 file changed, 55 insertions(+), 55 deletions(-) diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx index ee79037..128aed1 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx @@ -68,6 +68,59 @@ function DraggableNode({className, children, nodeType, onDrop}: DraggableNodePro ); } +function addNode(nodeType: string, position: XYPosition) { + const {setNodes} = useFlowStore.getState(); + const nds = useFlowStore.getState().nodes; + const newNode = () => { + switch (nodeType) { + case "phase": + { + const phaseNumber = nds.filter((node) => node.type === 'phase').length; + return { + id: `phase-${phaseNumber}`, + type: nodeType, + position, + data: {label: `"new"`, number: phaseNumber}, + }; + } + case "start": + return { + id: getId(), + type: nodeType, + position, + data: {label: `new start node`}, + }; + case "end": + return { + id: getId(), + type: nodeType, + position, + data: {label: `new end node`}, + }; + case "norm": + { + const normNumber = nds.filter((node) => node.type === 'norm').length; + return { + id: `norm-${normNumber}`, + type: nodeType, + position, + data: {label: `new norm node`}, + }; + } + default: { + return { + id: getId(), + type: nodeType, + position, + data: {label: `new default node`}, + }; + } + } + } + + setNodes(nds.concat(newNode())); +} + /** * the DndToolbar defines how the drag and drop toolbar component works * and includes the default onDrop behavior through handleNodeDrop @@ -75,8 +128,6 @@ function DraggableNode({className, children, nodeType, onDrop}: DraggableNodePro */ export function DndToolbar() { const {screenToFlowPosition} = useReactFlow(); - const {setNodes} = useReactFlow(); - /** * handleNodeDrop implements the default onDrop behavior */ @@ -94,61 +145,10 @@ export function DndToolbar() { // Create a new node and add it to the flow if (isInFlow) { const position = screenToFlowPosition(screenPosition); - - const newNode = () => { - switch (nodeType) { - case "phase": - { - const nds = useFlowStore.getState().nodes; - const phaseNumber = nds.filter((node) => node.type === 'phase').length; - return { - id: `phase-${phaseNumber}`, - type: nodeType, - position, - data: {label: `"new"`, number: phaseNumber}, - }; - } - - case "start": - return { - id: getId(), - type: nodeType, - position, - data: {label: `new start node`}, - }; - case "end": - return { - id: getId(), - type: nodeType, - position, - data: {label: `new end node`}, - }; - case "norm": - { - const nds = useFlowStore.getState().nodes; - const normNumber = nds.filter((node) => node.type === 'norm').length; - return { - id: `norm-${normNumber}`, - type: nodeType, - position, - data: {label: `new norm node`}, - }; - } - default: { - return { - id: getId(), - type: nodeType, - position, - data: {label: `new default node`}, - }; - } - } - } - - setNodes((nds) => nds.concat(newNode())); + addNode(nodeType, position); } }, - [setNodes, screenToFlowPosition], + [screenToFlowPosition], ); return ( -- 2.49.1 From a0324479e6a1ba05aebbe2e964b5f8fc70bb5eec Mon Sep 17 00:00:00 2001 From: JGerla Date: Sat, 25 Oct 2025 18:41:57 +0200 Subject: [PATCH 076/184] refactor: removed start and end nodes from the addNode functionality as they are not supported for manual adding removed unnecessary code. BREAKING: end and start nodes are no longer supported by the dnd addNode functionality. ref: N25B-114 --- .../components/DragDropSidebar.tsx | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx index 128aed1..5d626d3 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx @@ -80,23 +80,9 @@ function addNode(nodeType: string, position: XYPosition) { id: `phase-${phaseNumber}`, type: nodeType, position, - data: {label: `"new"`, number: phaseNumber}, + data: {label: 'new', number: phaseNumber}, }; } - case "start": - return { - id: getId(), - type: nodeType, - position, - data: {label: `new start node`}, - }; - case "end": - return { - id: getId(), - type: nodeType, - position, - data: {label: `new end node`}, - }; case "norm": { const normNumber = nds.filter((node) => node.type === 'norm').length; -- 2.49.1 From e1d6b08a1c9faafca213442c28c5b179b4d910f3 Mon Sep 17 00:00:00 2001 From: JGerla Date: Sat, 25 Oct 2025 18:58:41 +0200 Subject: [PATCH 077/184] refactor: removed start and end nodes from the addNode functionality as they are not supported for manual adding removed unnecessary code. BREAKING: end and start nodes are no longer supported by the dnd addNode functionality. ref: N25B-114 --- .../visualProgrammingUI/components/DragDropSidebar.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx index 5d626d3..3540b75 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx @@ -9,8 +9,9 @@ import { useRef, useState } from 'react'; -import styles from "../../VisProg.module.css" import useFlowStore from "../VisProgStores.tsx"; +import styles from "../../VisProg.module.css" + // Is used to make sure each subsequent node gets a unique id, so the ReactFlow implementation // can distinguish between different nodes. -- 2.49.1 From 9bfc39afa10399a979697be93f73b8facd6d4a2d Mon Sep 17 00:00:00 2001 From: JGerla Date: Sat, 25 Oct 2025 20:53:09 +0200 Subject: [PATCH 078/184] test: added test added test for addNode ref: N25B-114 --- __mocks__/@neodrag/react.ts | 3 +++ .../components/DragDropSidebar.tsx | 4 +++- .../components/DragDropSidebar.test.tsx | 20 +++++++++++++++++++ test/setupFlowTests.ts | 6 ++++-- 4 files changed, 30 insertions(+), 3 deletions(-) create mode 100644 __mocks__/@neodrag/react.ts create mode 100644 test/pages/visProgPage/visualProgrammingUI/components/DragDropSidebar.test.tsx diff --git a/__mocks__/@neodrag/react.ts b/__mocks__/@neodrag/react.ts new file mode 100644 index 0000000..7ea5455 --- /dev/null +++ b/__mocks__/@neodrag/react.ts @@ -0,0 +1,3 @@ +jest.mock('@neodrag/react', () => ({ + useDraggable: jest.fn(), +})); \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx index 3540b75..8317518 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx @@ -69,7 +69,9 @@ function DraggableNode({className, children, nodeType, onDrop}: DraggableNodePro ); } -function addNode(nodeType: string, position: XYPosition) { + +// eslint-disable-next-line react-refresh/only-export-components +export function addNode(nodeType: string, position: XYPosition) { const {setNodes} = useFlowStore.getState(); const nds = useFlowStore.getState().nodes; const newNode = () => { diff --git a/test/pages/visProgPage/visualProgrammingUI/components/DragDropSidebar.test.tsx b/test/pages/visProgPage/visualProgrammingUI/components/DragDropSidebar.test.tsx new file mode 100644 index 0000000..da90945 --- /dev/null +++ b/test/pages/visProgPage/visualProgrammingUI/components/DragDropSidebar.test.tsx @@ -0,0 +1,20 @@ +import { mockReactFlow } from '../../../../setupFlowTests.ts'; +import {act} from "@testing-library/react"; +import useFlowStore from "../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx"; +import {addNode} from "../../../../../src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx"; + + +beforeAll(() => { + mockReactFlow(); +}); + +describe('Drag-and-Drop sidebar', () => { + test.each(['phase', 'phase'])('new nodes get added correctly', (nodeType: string) => { + act(()=> { + addNode(nodeType, {x:100, y:100}); + }) + const updatedState = useFlowStore.getState(); + expect(updatedState.nodes.length).toBe(1); + expect(updatedState.nodes[0].type).toBe(nodeType); + }); +}); \ No newline at end of file diff --git a/test/setupFlowTests.ts b/test/setupFlowTests.ts index 99920df..21a4945 100644 --- a/test/setupFlowTests.ts +++ b/test/setupFlowTests.ts @@ -58,11 +58,13 @@ export const mockReactFlow = () => { (globalThis.SVGElement as never).prototype.getBBox = () => ({ x: 0, y: 0, - width: 0, - height: 0, + width: 200, + height: 200, }); }; + + beforeAll(() => { useFlowStore.setState({ nodes: [], -- 2.49.1 From 416025bf3fb200413803872a22f0d4717c407ad1 Mon Sep 17 00:00:00 2001 From: JGerla Date: Sat, 25 Oct 2025 20:59:41 +0200 Subject: [PATCH 079/184] test: added test for nodeId generation added test for addNode id generation ref: N25B-114 --- .../components/DragDropSidebar.test.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/test/pages/visProgPage/visualProgrammingUI/components/DragDropSidebar.test.tsx b/test/pages/visProgPage/visualProgrammingUI/components/DragDropSidebar.test.tsx index da90945..c1f9979 100644 --- a/test/pages/visProgPage/visualProgrammingUI/components/DragDropSidebar.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/components/DragDropSidebar.test.tsx @@ -17,4 +17,14 @@ describe('Drag-and-Drop sidebar', () => { expect(updatedState.nodes.length).toBe(1); expect(updatedState.nodes[0].type).toBe(nodeType); }); + test.each(['phase', 'norm'])('new nodes get correct Id', (nodeType) => { + act(()=> { + addNode(nodeType, {x:100, y:100}); + addNode(nodeType, {x:100, y:100}); + }) + const updatedState = useFlowStore.getState(); + expect(updatedState.nodes.length).toBe(2); + expect(updatedState.nodes[0].id).toBe(`${nodeType}-0`); + expect(updatedState.nodes[1].id).toBe(`${nodeType}-1`); + }); }); \ No newline at end of file -- 2.49.1 From 7b12ae1f5e665a1618c548b3366144c2b5c97cf8 Mon Sep 17 00:00:00 2001 From: JGerla Date: Sat, 25 Oct 2025 21:09:25 +0200 Subject: [PATCH 080/184] test: added test for addNode error added test for addNode error thrown on unexpected node type ref: N25B-114 --- .../components/DragDropSidebar.tsx | 12 +----------- .../components/DragDropSidebar.test.tsx | 3 +++ 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx index 8317518..383f72c 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx @@ -13,11 +13,6 @@ import useFlowStore from "../VisProgStores.tsx"; import styles from "../../VisProg.module.css" -// Is used to make sure each subsequent node gets a unique id, so the ReactFlow implementation -// can distinguish between different nodes. -let id = 0; -const getId = () => `dndnode_${id++}`; - /** * DraggableNodeProps dictates the type properties of a DraggableNode */ @@ -97,12 +92,7 @@ export function addNode(nodeType: string, position: XYPosition) { }; } default: { - return { - id: getId(), - type: nodeType, - position, - data: {label: `new default node`}, - }; + throw new Error(`Node ${nodeType} not found`); } } } diff --git a/test/pages/visProgPage/visualProgrammingUI/components/DragDropSidebar.test.tsx b/test/pages/visProgPage/visualProgrammingUI/components/DragDropSidebar.test.tsx index c1f9979..ae9b88c 100644 --- a/test/pages/visProgPage/visualProgrammingUI/components/DragDropSidebar.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/components/DragDropSidebar.test.tsx @@ -27,4 +27,7 @@ describe('Drag-and-Drop sidebar', () => { expect(updatedState.nodes[0].id).toBe(`${nodeType}-0`); expect(updatedState.nodes[1].id).toBe(`${nodeType}-1`); }); + test('throws error on unexpected node type', () => { + expect(() => addNode('I do not Exist', {x:100, y:100})).toThrow("Node I do not Exist not found"); + }) }); \ No newline at end of file -- 2.49.1 From 4181454a73b82180a4240a0789c5a196ac9291ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Thu, 30 Oct 2025 13:05:56 +0100 Subject: [PATCH 081/184] feat: show robots page easier - quick connected sign. Quick reload - no need for manual reloads or anything. ref: N25B-142 --- package-lock.json | 126 +++++++++++++ src/pages/ConnectedRobots/ConnectedRobots.tsx | 176 ++---------------- 2 files changed, 144 insertions(+), 158 deletions(-) diff --git a/package-lock.json b/package-lock.json index b4dc078..665dad5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1967,6 +1967,12 @@ "@tybys/wasm-util": "^0.10.0" } }, + "node_modules/@neodrag/react": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@neodrag/react/-/react-2.3.1.tgz", + "integrity": "sha512-mOVefo3mFmaVLs9PB5F5wMXnnclG81qjOaPHyf8YZUnw/Ciz0pAqyJDwDJk0nPTIK5I2x1JdjXSchGNdCxZNRQ==", + "license": "MIT" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -3769,6 +3775,12 @@ "dev": true, "license": "MIT" }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -3951,6 +3963,111 @@ "devOptional": true, "license": "MIT" }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/data-urls": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", @@ -7591,6 +7708,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", diff --git a/src/pages/ConnectedRobots/ConnectedRobots.tsx b/src/pages/ConnectedRobots/ConnectedRobots.tsx index abce238..a6f7ce7 100644 --- a/src/pages/ConnectedRobots/ConnectedRobots.tsx +++ b/src/pages/ConnectedRobots/ConnectedRobots.tsx @@ -1,178 +1,38 @@ -import { useEffect } from 'react' -import Logging from '../Logging/Logging'; - -// Define the robot type -type Robot = { - id: string; - name: string; - port: number; -}; - -// Define the expected arguments -type ConnectedRobotsProps = { - connectedRobots: Robot[]; - setConnectedRobots: React.Dispatch>; -}; - -export default function ConnectedRobots({ - connectedRobots, setConnectedRobots }: ConnectedRobotsProps) { +import { useEffect, useState } from 'react' +export default function ConnectedRobots() { + + const [connected, setConnected] = useState(null); useEffect(() => { - const eventSource = new EventSource("http://localhost:8000/sse"); + // We're excepting 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) => { + + // Receive message and parse + console.log("received message:", event.data); try { const data = JSON.parse(event.data); - // Example: data = { event: "robot_connected", id: "pepper_robot1", name: "Pepper", port: 1234 } - if (data.event === "robot_connected") { - - // Safeguard id in request. - if (data.id === null || data.id === undefined) { - console.log(`Missing robot id in connection request. - Use format: 'data: {event = 'robot_connected', id = , (optional) name = , (optional) port = }'.`) - return () => eventSource.close(); - } - - // Safeguard duplicates - if (connectedRobots.some(robot => robot.id === data.id)) - console.log("connection request was sent for id: ", data.id, - " however this id was already present at current time"); - - // Add to connected robots while checking name and port for undefineds. - else { - const name = data.name ?? "no given name"; - const port = typeof data.port === "number" ? data.port : -1; - setConnectedRobots(robots => [...robots, { id: data.id, name: name, port: port }]); - } + // Set connected to value. + try { + setConnected(data) } - if (data.event === "robot_disconnected") { - // Safeguard id in request. - if (data.id === null || data.id === undefined) { - console.log("Missing robot id in connection request. Use format: 'data: {event = 'robot_disconnected', id = }'."); - return () => eventSource.close(); - } - - // Filter out same ids (should only be one) - setConnectedRobots(robots => robots.filter(robot => robot.id !== data.id)); - } - if (data.event === "robot_list") { - if (data.list === null || data.list === undefined) { - console.log("Missing list in robot_list request. Use format: 'data: {event = 'robot_list', list = }'."); - return () => eventSource.close(); - } - - // Set the robot list to the one found in CB - setConnectedRobots(data.list); + catch { + console.log("couldnt extract connected from incoming ping data") } } catch { - console.log("Unparsable SSE message:", event.data); + console.log("Ping message not in correct format:", event.data); } }; return () => eventSource.close(); - }, [connectedRobots]); + }); return (
-

Robots Connected

+

Is robot currently connected?

-

Connected Robots

-
    - {connectedRobots.map(robot => - // Map all the robots in an unordered list -
  • - {robot.name} (ID: {robot.id}, Port: {robot.port === -1 ? "No given port" : robot.port}) -
  • - )} -
-
-
-
- - - - -
+

Robot is currently: {connected == null ? "checking..." : (connected ? "connected! 🟢" : "not connected... 🔴")}

); -- 2.49.1 From 6a88aa3d755db2ec9f9ed76019545846f39f3a16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Thu, 30 Oct 2025 14:57:50 +0100 Subject: [PATCH 082/184] merge branch dev into show-connected-robots pt2 --- package-lock.json | 127 ------------------------------ src/App.tsx | 3 - src/pages/VisProgPage/VisProg.tsx | 11 --- 3 files changed, 141 deletions(-) diff --git a/package-lock.json b/package-lock.json index b3caba6..c7346fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3323,69 +3323,6 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, - "node_modules/@xyflow/react": { - "version": "12.8.6", - "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.8.6.tgz", - "integrity": "sha512-SksAm2m4ySupjChphMmzvm55djtgMDPr+eovPDdTnyGvShf73cvydfoBfWDFllooIQ4IaiUL5yfxHRwU0c37EA==", - "license": "MIT", - "dependencies": { - "@xyflow/system": "0.0.70", - "classcat": "^5.0.3", - "zustand": "^4.4.0" - }, - "peerDependencies": { - "react": ">=17", - "react-dom": ">=17" - } - }, -<<<<<<< HEAD -======= - "node_modules/@xyflow/react/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 - } - } - }, ->>>>>>> origin/dev - "node_modules/@xyflow/system": { - "version": "0.0.70", - "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.70.tgz", - "integrity": "sha512-PpC//u9zxdjj0tfTSmZrg3+sRbTz6kop/Amky44U2Dl51sxzDTIUfXMwETOYpmr2dqICWXBIJwXL2a9QWtX2XA==", - "license": "MIT", - "dependencies": { - "@types/d3-drag": "^3.0.7", - "@types/d3-interpolate": "^3.0.4", - "@types/d3-selection": "^3.0.10", - "@types/d3-transition": "^3.0.8", - "@types/d3-zoom": "^3.0.8", - "d3-drag": "^3.0.0", - "d3-interpolate": "^3.0.1", - "d3-selection": "^3.0.0", - "d3-zoom": "^3.0.0" - } - }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -7740,21 +7677,6 @@ "punycode": "^2.1.0" } }, - "node_modules/use-sync-external-store": { -<<<<<<< HEAD - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", - "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", -======= - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", - "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", ->>>>>>> origin/dev - "license": "MIT", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, "node_modules/v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", @@ -8237,55 +8159,6 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } - }, - "node_modules/zustand": { -<<<<<<< HEAD - "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" -======= - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz", - "integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==", - "license": "MIT", - "engines": { - "node": ">=12.20.0" - }, - "peerDependencies": { - "@types/react": ">=18.0.0", - "immer": ">=9.0.6", - "react": ">=18.0.0", - "use-sync-external-store": ">=1.2.0" ->>>>>>> origin/dev - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "immer": { - "optional": true - }, - "react": { - "optional": true -<<<<<<< HEAD -======= - }, - "use-sync-external-store": { - "optional": true ->>>>>>> origin/dev - } - } } } } diff --git a/src/App.tsx b/src/App.tsx index fd9404c..819dae4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,11 +4,8 @@ import './App.css' import TemplatePage from './pages/TemplatePage/Template.tsx' import Home from './pages/Home/Home.tsx' import Robot from './pages/Robot/Robot.tsx'; -<<<<<<< HEAD import ConnectedRobots from './pages/ConnectedRobots/ConnectedRobots.tsx' -======= import VisProg from "./pages/VisProgPage/VisProg.tsx"; ->>>>>>> origin/dev function App(){ diff --git a/src/pages/VisProgPage/VisProg.tsx b/src/pages/VisProgPage/VisProg.tsx index ec2078c..4b8944c 100644 --- a/src/pages/VisProgPage/VisProg.tsx +++ b/src/pages/VisProgPage/VisProg.tsx @@ -1,13 +1,3 @@ -<<<<<<< HEAD -import VisProgUI from "../../visualProgrammingUI/VisProgUI.tsx"; - -function VisProgPage() { - return ( - <> - - - ) -======= import { Background, Controls, @@ -144,7 +134,6 @@ function VisProgPage() { ) ->>>>>>> origin/dev } export default VisProgPage \ No newline at end of file -- 2.49.1 From 5e707224cffabb3320e87e8810f77fecb1b8305e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Thu, 30 Oct 2025 15:47:09 +0100 Subject: [PATCH 083/184] feat: Show connected robots finished with unit test 94% coverage ref: N25B-142 --- package-lock.json | 98 +++++++++++++++++ src/App.tsx | 22 +--- src/pages/ConnectedRobots/ConnectedRobots.tsx | 6 ++ .../connectedRobots/ConnectedRobots.test.tsx | 102 ++++++++++++++++++ 4 files changed, 207 insertions(+), 21 deletions(-) create mode 100644 test/pages/connectedRobots/ConnectedRobots.test.tsx diff --git a/package-lock.json b/package-lock.json index c7346fa..40f413f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3323,6 +3323,66 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@xyflow/react": { + "version": "12.9.1", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.9.1.tgz", + "integrity": "sha512-JRPCT5p7NnPdVSIh15AFvUSSm+8GUyz2I6iuBEC1LG2lKgig/L48AM/ImMHCc3ZUCg+AgTOJDaX2fcRyPA9BTA==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.72", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@xyflow/react/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/@xyflow/system": { + "version": "0.0.72", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.72.tgz", + "integrity": "sha512-WBI5Aau0fXTXwxHPzceLNS6QdXggSWnGjDtj/gG669crApN8+SCmEtkBth1m7r6pStNo/5fI9McEi7Dk0ymCLA==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -7677,6 +7737,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", @@ -8159,6 +8228,35 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zustand": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz", + "integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } } } } diff --git a/src/App.tsx b/src/App.tsx index 819dae4..4fb4c42 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,4 @@ import { Routes, Route, Link } from 'react-router' -import { useState } from 'react' import './App.css' import TemplatePage from './pages/TemplatePage/Template.tsx' import Home from './pages/Home/Home.tsx' @@ -9,16 +8,6 @@ import VisProg from "./pages/VisProgPage/VisProg.tsx"; function App(){ - // Define what our conencted robot should include - type Robot = { - id: string; - name: string; - port: number; - }; - - // (Acces to) the array of connected robots - const [connectedRobots, setConnectedRobots] = useState([]); - return (
@@ -30,16 +19,7 @@ function App(){ } /> } /> } /> - - } - /> + } />
diff --git a/src/pages/ConnectedRobots/ConnectedRobots.tsx b/src/pages/ConnectedRobots/ConnectedRobots.tsx index a6f7ce7..8b4b898 100644 --- a/src/pages/ConnectedRobots/ConnectedRobots.tsx +++ b/src/pages/ConnectedRobots/ConnectedRobots.tsx @@ -3,6 +3,9 @@ import { useEffect, useState } from 'react' export default function ConnectedRobots() { const [connected, setConnected] = useState(null); + + + useEffect(() => { // We're excepting a stream of data like that looks like this: `data = False` or `data = True` const eventSource = new EventSource("http://localhost:8000/robot/ping_stream"); @@ -33,6 +36,9 @@ export default function ConnectedRobots() {

Is robot currently connected?

Robot is currently: {connected == null ? "checking..." : (connected ? "connected! 🟢" : "not connected... 🔴")}

+

+ {connected == null ? "If checking continues, make sure CB is properly loaded with robot at least once." : ""} +

); diff --git a/test/pages/connectedRobots/ConnectedRobots.test.tsx b/test/pages/connectedRobots/ConnectedRobots.test.tsx new file mode 100644 index 0000000..ffea6e3 --- /dev/null +++ b/test/pages/connectedRobots/ConnectedRobots.test.tsx @@ -0,0 +1,102 @@ +import { render, screen, act, cleanup, waitFor } from '@testing-library/react'; +import ConnectedRobots from '../../../src/pages/ConnectedRobots/ConnectedRobots'; + +// Mock event source +const mockInstances: MockEventSource[] = []; +class MockEventSource { + url: string; + onmessage: ((event: MessageEvent) => void) | null = null; + closed = false; + + constructor(url: string) { + this.url = url; + mockInstances.push(this); + } + + sendMessage(data: any) { + // Trigger whatever the component listens to + this.onmessage?.({ data } as MessageEvent); + } + + close() { + this.closed = true; + } +} + +// mock event source generation with fake function that returns our fake mock source +beforeAll(() => { + (globalThis as any).EventSource = jest.fn((url: string) => new MockEventSource(url)); +}); + +// clean after tests +afterEach(() => { + cleanup(); + jest.restoreAllMocks(); + mockInstances.length = 0; +}); + +describe('ConnectedRobots', () => { + test('renders initial state correctly', () => { + render(); + + // Check initial texts (before connection) + expect(screen.getByText('Is robot currently connected?')).toBeInTheDocument(); + expect(screen.getByText(/Robot is currently:\s*checking/i)).toBeInTheDocument(); + expect( + screen.getByText(/If checking continues, make sure CB is properly loaded/i) + ).toBeInTheDocument(); + }); + + test('updates to connected when message data is true', async () => { + render(); + const eventSource = mockInstances[0]; + expect(eventSource).toBeDefined(); + + // Check state after getting 'true' message + await act(async () => { + eventSource.sendMessage('true'); + }); + + await waitFor(() => { + expect(screen.getByText(/connected! 🟢/i)).toBeInTheDocument(); + }); + }); + + test('updates to not connected when message data is false', async () => { + render(); + const eventSource = mockInstances[0]; + + // Check statew after getting 'false' message + await act(async () => { + eventSource.sendMessage('false'); + }); + + await waitFor(() => { + expect(screen.getByText(/not connected.*🔴/i)).toBeInTheDocument(); + }); + }); + + test('handles invalid JSON gracefully', async () => { + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + render(); + const eventSource = mockInstances[0]; + + await act(async () => { + eventSource.sendMessage('not-json'); + }); + + expect(logSpy).toHaveBeenCalledWith( + 'Ping message not in correct format:', + 'not-json' + ); + }); + + test('closes EventSource on unmount', () => { + render(); + const eventSource = mockInstances[0]; + const closeSpy = jest.spyOn(eventSource, 'close'); + cleanup(); + expect(closeSpy).toHaveBeenCalled(); + expect(eventSource.closed).toBe(true); + }); +}); \ No newline at end of file -- 2.49.1 From 333bd6e6fd1b7db071e1b1399d0260de606a375f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Wed, 5 Nov 2025 16:11:36 +0100 Subject: [PATCH 084/184] chore: single typing change --- test/pages/connectedRobots/ConnectedRobots.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/pages/connectedRobots/ConnectedRobots.test.tsx b/test/pages/connectedRobots/ConnectedRobots.test.tsx index ffea6e3..c2d3749 100644 --- a/test/pages/connectedRobots/ConnectedRobots.test.tsx +++ b/test/pages/connectedRobots/ConnectedRobots.test.tsx @@ -13,7 +13,7 @@ class MockEventSource { mockInstances.push(this); } - sendMessage(data: any) { + sendMessage(data: string) { // Trigger whatever the component listens to this.onmessage?.({ data } as MessageEvent); } -- 2.49.1 From c4845c673871b2bddf2d0f664b98d1704a234f5f Mon Sep 17 00:00:00 2001 From: "Gerla, J. (Justin)" Date: Wed, 5 Nov 2025 15:21:59 +0000 Subject: [PATCH 085/184] feat: added a behavior program reduction algorithm --- src/pages/VisProgPage/VisProg.tsx | 24 +- .../visualProgrammingUI/GraphReducer.ts | 188 ++++ .../visualProgrammingUI/GraphReducerTypes.ts | 106 ++ .../visualProgrammingUI/VisProgStores.tsx | 11 +- .../visualProgrammingUI/VisProgTypes.tsx | 14 +- .../components/DragDropSidebar.tsx | 35 +- .../components/NodeDefinitions.tsx | 106 +- .../visualProgrammingUI/GraphReducer.test.ts | 986 ++++++++++++++++++ .../components/DragDropSidebar.test.tsx | 4 +- 9 files changed, 1401 insertions(+), 73 deletions(-) create mode 100644 src/pages/VisProgPage/visualProgrammingUI/GraphReducer.ts create mode 100644 src/pages/VisProgPage/visualProgrammingUI/GraphReducerTypes.ts create mode 100644 test/pages/visProgPage/visualProgrammingUI/GraphReducer.test.ts diff --git a/src/pages/VisProgPage/VisProg.tsx b/src/pages/VisProgPage/VisProg.tsx index 4b8944c..8208a70 100644 --- a/src/pages/VisProgPage/VisProg.tsx +++ b/src/pages/VisProgPage/VisProg.tsx @@ -10,12 +10,13 @@ import '@xyflow/react/dist/style.css'; import {useShallow} from 'zustand/react/shallow'; import { - StartNode, - EndNode, - PhaseNode, - NormNode + StartNodeComponent, + EndNodeComponent, + PhaseNodeComponent, + NormNodeComponent } from './visualProgrammingUI/components/NodeDefinitions.tsx'; import {DndToolbar} from './visualProgrammingUI/components/DragDropSidebar.tsx'; +import graphReducer from "./visualProgrammingUI/GraphReducer.ts"; import useFlowStore from './visualProgrammingUI/VisProgStores.tsx'; import type {FlowState} from './visualProgrammingUI/VisProgTypes.tsx'; import styles from './VisProg.module.css' @@ -26,10 +27,10 @@ import styles from './VisProg.module.css' * contains the types of all nodes that are available in the editor */ const NODE_TYPES = { - start: StartNode, - end: EndNode, - phase: PhaseNode, - norm: NormNode + start: StartNodeComponent, + end: EndNodeComponent, + phase: PhaseNodeComponent, + norm: NormNodeComponent }; /** @@ -123,6 +124,12 @@ function VisualProgrammingUI() { ); } +// currently outputs the prepared program to the console +function runProgram() { + const program = graphReducer(); + console.log(program); +} + /** * houses the entire page, so also UI elements * that are not a part of the Visual Programming UI @@ -132,6 +139,7 @@ function VisProgPage() { return ( <> + ) } diff --git a/src/pages/VisProgPage/visualProgrammingUI/GraphReducer.ts b/src/pages/VisProgPage/visualProgrammingUI/GraphReducer.ts new file mode 100644 index 0000000..138eb82 --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/GraphReducer.ts @@ -0,0 +1,188 @@ +import { + type Edge, + getIncomers, + getOutgoers +} from '@xyflow/react'; +import useFlowStore from "./VisProgStores.tsx"; +import type { + BehaviorProgram, + GoalData, + GoalReducer, + GraphPreprocessor, + NormData, + NormReducer, + OrderedPhases, + Phase, + PhaseReducer, + PreparedGraph, + PreparedPhase +} from "./GraphReducerTypes.ts"; +import type { + AppNode, + GoalNode, + NormNode, + PhaseNode +} from "./VisProgTypes.tsx"; + +/** + * Reduces the current graph inside the visual programming editor into a BehaviorProgram + * + * @param {GraphPreprocessor} graphPreprocessor + * @param {PhaseReducer} phaseReducer + * @param {NormReducer} normReducer + * @param {GoalReducer} goalReducer + * @returns {BehaviorProgram} + */ +export default function graphReducer( + graphPreprocessor: GraphPreprocessor = defaultGraphPreprocessor, + phaseReducer: PhaseReducer = defaultPhaseReducer, + normReducer: NormReducer = defaultNormReducer, + goalReducer: GoalReducer = defaultGoalReducer +) : BehaviorProgram { + const nodes: AppNode[] = useFlowStore.getState().nodes; + const edges: Edge[] = useFlowStore.getState().edges; + const preparedGraph: PreparedGraph = graphPreprocessor(nodes, edges); + + return preparedGraph.map((preparedPhase: PreparedPhase) : Phase => + phaseReducer( + preparedPhase, + normReducer, + goalReducer + )); +}; + +/** + * reduces a single preparedPhase to a Phase object + * the Phase object describes a single phase in a BehaviorProgram + * + * @param {PreparedPhase} phase + * @param {NormReducer} normReducer + * @param {GoalReducer} goalReducer + * @returns {Phase} + */ +export function defaultPhaseReducer( + phase: PreparedPhase, + normReducer: NormReducer = defaultNormReducer, + goalReducer: GoalReducer = defaultGoalReducer +) : Phase { + return { + id: phase.phaseNode.id, + name: phase.phaseNode.data.label, + nextPhaseId: phase.nextPhaseId, + phaseData: { + norms: phase.connectedNorms.map(normReducer), + goals: phase.connectedGoals.map(goalReducer) + } + } +} + +/** + * the default implementation of the goalNode reducer function + * + * @param {GoalNode} node + * @returns {GoalData} + */ +function defaultGoalReducer(node: GoalNode) : GoalData { + return { + id: node.id, + name: node.data.label, + value: node.data.value + } +} + +/** + * the default implementation of the normNode reducer function + * + * @param {NormNode} node + * @returns {NormData} + */ +function defaultNormReducer(node: NormNode) :NormData { + return { + id: node.id, + name: node.data.label, + value: node.data.value + } +} + +// Graph preprocessing functions: + +/** + * Preprocesses the provide state of the behavior editor graph, preparing it for further processing in + * the graphReducer function + * + * @param {AppNode[]} nodes + * @param {Edge[]} edges + * @returns {PreparedGraph} + */ +export function defaultGraphPreprocessor(nodes: AppNode[], edges: Edge[]) : PreparedGraph { + const norms : NormNode[] = nodes.filter((node) => node.type === 'norm') as NormNode[]; + const goals : GoalNode[] = nodes.filter((node) => node.type === 'goal') as GoalNode[]; + const orderedPhases : OrderedPhases = orderPhases(nodes, edges); + + return orderedPhases.phaseNodes.map((phase: PhaseNode) : PreparedPhase => { + const nextPhase = orderedPhases.connections.get(phase.id); + return { + phaseNode: phase, + nextPhaseId: nextPhase as string, + connectedNorms: getIncomers({id: phase.id}, norms,edges), + connectedGoals: getIncomers({id: phase.id}, goals,edges) + }; + }); +} + +/** + * orderPhases takes the state of the graph created by the editor and turns it into an OrderedPhases object. + * + * @param {AppNode[]} nodes + * @param {Edge[]} edges + * @returns {OrderedPhases} + */ +export function orderPhases(nodes: AppNode[],edges: Edge[]) : OrderedPhases { + // find the first Phase node + const phaseNodes : PhaseNode[] = nodes.filter((node) => node.type === 'phase') as PhaseNode[]; + const startNodeIndex = nodes.findIndex((node : AppNode):boolean => {return (node.type === 'start');}); + const firstPhaseNode = getOutgoers({ id: nodes[startNodeIndex].id },phaseNodes,edges); + + // recursively adds the phase nodes to a list in the order they are connected in the graph + const nextPhase = ( + currentIndex: number, + { phaseNodes: phases, connections: connections} : OrderedPhases + ) : OrderedPhases => { + // get the current phase and the next phases; + const currentPhase = phases[currentIndex]; + const nextPhaseNodes = getOutgoers(currentPhase,phaseNodes,edges); + const nextNodes = getOutgoers(currentPhase,nodes, edges); + + // handles adding of the next phase to the chain, and error handle if an invalid state is received + if (nextPhaseNodes.length === 1 && nextNodes.length === 1) { + connections.set(currentPhase.id, nextPhaseNodes[0].id); + return nextPhase(phases.push(nextPhaseNodes[0] as PhaseNode) - 1, {phaseNodes: phases, connections: connections}); + } else { + // handle erroneous states + if (nextNodes.length === 0){ + throw new Error(`| INVALID PROGRAM | the source handle of "${currentPhase.id}" doesn't have any outgoing connections`); + } else { + if (nextNodes.length > 1) { + throw new Error(`| INVALID PROGRAM | the source handle of "${currentPhase.id}" connects to too many targets`); + } else { + if (nextNodes[0].type === "end"){ + connections.set(currentPhase.id, "end"); + // returns the final output of the function + return { phaseNodes: phases, connections: connections}; + } else { + throw new Error(`| INVALID PROGRAM | the node "${nextNodes[0].id}" that "${currentPhase.id}" connects to is not a phase or end node`); + } + } + } + } + } + // initializes the Map describing the connections between phase nodes + // we need this Map to make sure we preserve this information, + // so we don't need to do checks on the entire set of edges in further stages of the reduction algorithm + const connections : Map = new Map(); + + // returns an empty list if no phase nodes are present, otherwise returns an ordered list of phaseNodes + if (firstPhaseNode.length > 0) { + return nextPhase(0, {phaseNodes: [firstPhaseNode[0] as PhaseNode], connections: connections}) + } else { return {phaseNodes: [], connections: connections} } +} \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/GraphReducerTypes.ts b/src/pages/VisProgPage/visualProgrammingUI/GraphReducerTypes.ts new file mode 100644 index 0000000..9151b56 --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/GraphReducerTypes.ts @@ -0,0 +1,106 @@ +import type {Edge} from "@xyflow/react"; +import type {AppNode, GoalNode, NormNode, PhaseNode} from "./VisProgTypes.tsx"; + + +/** + * defines how a norm is represented in the simplified behavior program + */ +export type NormData = { + id: string; + name: string; + value: string; +}; + +/** + * defines how a goal is represented in the simplified behavior program + */ +export type GoalData = { + id: string; + name: string; + value: string; +}; + +/** + * definition of a PhaseData object, it contains all phaseData that is relevant + * for further processing and execution of a phase. + */ +export type PhaseData = { + norms: NormData[]; + goals: GoalData[]; +}; + +/** + * Describes a single phase within the simplified representation of a behavior program, + * + * Contains: + * - the id of the described phase, + * - the name of the described phase, + * - the id of the next phase in the user defined behavior program + * - the data property of the described phase node + * + * @NOTE at the moment the type definitions do not support branching programs, + * if branching of phases is to be supported in the future, the type definition for Phase has to be updated + */ +export type Phase = { + id: string; + name: string; + nextPhaseId: string; + phaseData: PhaseData; +}; + +/** + * Describes a simplified behavior program as a list of Phase objects + */ +export type BehaviorProgram = Phase[]; + + + +export type NormReducer = (node: NormNode) => NormData; +export type GoalReducer = (node: GoalNode) => GoalData; +export type PhaseReducer = ( + preparedPhase: PreparedPhase, + normReducer: NormReducer, + goalReducer: GoalReducer +) => Phase; + +/** + * contains: + * + * - list of phases, sorted based on position in chain between the start and end node + * - a dictionary containing all outgoing connections, + * to other phase or end nodes, for each phase node uses the id of the source node as key + * and the id of the target node as value + * + */ +export type OrderedPhases = { + phaseNodes: PhaseNode[]; + connections: Map; +}; + +/** + * A single prepared phase, + * contains: + * - the described phaseNode, + * - the id of the next phaseNode or "end" for the end node + * - a list of the normNodes that are connected to the described phase + * - a list of the goalNodes that are connected to the described phase + */ +export type PreparedPhase = { + phaseNode: PhaseNode; + nextPhaseId: string; + connectedNorms: NormNode[]; + connectedGoals: GoalNode[]; +}; + +/** + * a list of PreparedPhase objects, + * describes the preprocessed state of a program, + * before the contents of the node + */ +export type PreparedGraph = PreparedPhase[]; + +export type GraphPreprocessor = (nodes: AppNode[], edges: Edge[]) => PreparedGraph; + + + + diff --git a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx index 10d0142..e27fb28 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx @@ -20,7 +20,7 @@ const initialNodes = [ data: {label: 'start'} }, { - id: 'genericPhase', + id: 'phase-1', type: 'phase', position: {x: 0, y: 150}, data: {label: 'Generic Phase', number: 1}, @@ -38,9 +38,14 @@ const initialNodes = [ */ const initialEdges = [ { - id: 'start-end', + id: 'start-phase-1', source: 'start', - target: 'end' + target: 'phase-1', + }, + { + id: 'phase-1-end', + source: 'phase-1', + target: 'end', } ]; diff --git a/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx b/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx index f5ede86..378f9be 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx @@ -7,12 +7,24 @@ import { type OnReconnect, } from '@xyflow/react'; + +type defaultNodeData = { + label: string; +}; + +export type StartNode = Node; +export type EndNode = Node; +export type GoalNode = Node; +export type NormNode = Node; +export type PhaseNode = Node; + + /** * a type meant to house different node types, currently not used * but will allow us to more clearly define nodeTypes when we implement * computation of the Graph inside the ReactFlow editor */ -export type AppNode = Node; +export type AppNode = Node | StartNode | EndNode | NormNode | GoalNode | PhaseNode; /** diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx index 383f72c..c9e1496 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx @@ -11,6 +11,8 @@ import { } from 'react'; import useFlowStore from "../VisProgStores.tsx"; import styles from "../../VisProg.module.css" +import type {AppNode, PhaseNode, NormNode} from "../VisProgTypes.tsx"; + /** @@ -68,28 +70,45 @@ function DraggableNode({className, children, nodeType, onDrop}: DraggableNodePro // eslint-disable-next-line react-refresh/only-export-components export function addNode(nodeType: string, position: XYPosition) { const {setNodes} = useFlowStore.getState(); - const nds = useFlowStore.getState().nodes; + const nds : AppNode[] = useFlowStore.getState().nodes; const newNode = () => { switch (nodeType) { case "phase": { - const phaseNumber = nds.filter((node) => node.type === 'phase').length; - return { + const phaseNodes= nds.filter((node) => node.type === 'phase'); + let phaseNumber; + if (phaseNodes.length > 0) { + const finalPhaseId : number = +(phaseNodes[phaseNodes.length - 1].id.split('-')[1]); + phaseNumber = finalPhaseId + 1; + } else { + phaseNumber = 1; + } + const phaseNode : PhaseNode = { id: `phase-${phaseNumber}`, type: nodeType, position, data: {label: 'new', number: phaseNumber}, - }; + } + return phaseNode; } case "norm": { - const normNumber = nds.filter((node) => node.type === 'norm').length; - return { + const normNodes= nds.filter((node) => node.type === 'norm'); + let normNumber + if (normNodes.length > 0) { + const finalNormId : number = +(normNodes[normNodes.length - 1].id.split('-')[1]); + normNumber = finalNormId + 1; + } else { + normNumber = 1; + } + + const normNode : NormNode = { id: `norm-${normNumber}`, type: nodeType, position, - data: {label: `new norm node`}, - }; + data: {label: `new norm node`, value: "Pepper should be formal"}, + } + return normNode; } default: { throw new Error(`Node ${nodeType} not found`); diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/NodeDefinitions.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/NodeDefinitions.tsx index 63765b5..f74dd2b 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/NodeDefinitions.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/NodeDefinitions.tsx @@ -1,32 +1,32 @@ -import {Handle, NodeToolbar, Position} from '@xyflow/react'; +import {Handle, type NodeProps, NodeToolbar, Position} from '@xyflow/react'; import '@xyflow/react/dist/style.css'; import styles from '../../VisProg.module.css'; import useFlowStore from "../VisProgStores.tsx"; +import type { + StartNode, + EndNode, + PhaseNode, + NormNode +} from "../VisProgTypes.tsx"; -// Contains the datatypes for the data inside our NodeTypes -// this has to be improved or adapted to suit our implementation for computing the graph -// into a format that is useful for the Control Backend - -type defaultNodeData = { - label: string; -}; - -type startNodeData = defaultNodeData; -type endNodeData = defaultNodeData; -type normNodeData = defaultNodeData; -type phaseNodeData = defaultNodeData & { - number: number; -}; - -export type nodeData = defaultNodeData | startNodeData | phaseNodeData | endNodeData; - -// Node Toolbar definition, contains node delete functionality +// type ToolbarProps = { nodeId: string; allowDelete: boolean; }; + +/** + * Node Toolbar definition: + * handles: node deleting functionality + * can be added to any custom node component as a React component + * + * @param {string} nodeId + * @param {boolean} allowDelete + * @returns {React.JSX.Element} + * @constructor + */ export function Toolbar({nodeId, allowDelete}: ToolbarProps) { const {deleteNode} = useFlowStore(); @@ -42,14 +42,15 @@ export function Toolbar({nodeId, allowDelete}: ToolbarProps) { // Definitions of Nodes -// Start Node definition: - -type StartNodeProps = { - id: string; - data: startNodeData; -}; - -export const StartNode = ({id, data}: StartNodeProps) => { +/** + * Start Node definition: + * + * @param {string} id + * @param {defaultNodeData} data + * @returns {React.JSX.Element} + * @constructor + */ +export const StartNodeComponent = ({id, data}: NodeProps) => { return ( <> @@ -62,14 +63,15 @@ export const StartNode = ({id, data}: StartNodeProps) => { }; -// End node definition: - -type EndNodeProps = { - id: string; - data: endNodeData; -}; - -export const EndNode = ({id, data}: EndNodeProps) => { +/** + * End node definition: + * + * @param {string} id + * @param {defaultNodeData} data + * @returns {React.JSX.Element} + * @constructor + */ +export const EndNodeComponent = ({id, data}: NodeProps) => { return ( <> @@ -82,14 +84,15 @@ export const EndNode = ({id, data}: EndNodeProps) => { }; -// Phase node definition: - -type PhaseNodeProps = { - id: string; - data: phaseNodeData; -}; - -export const PhaseNode = ({id, data}: PhaseNodeProps) => { +/** + * Phase node definition: + * + * @param {string} id + * @param {defaultNodeData & {number: number}} data + * @returns {React.JSX.Element} + * @constructor + */ +export const PhaseNodeComponent = ({id, data}: NodeProps) => { return ( <> @@ -104,14 +107,15 @@ export const PhaseNode = ({id, data}: PhaseNodeProps) => { }; -// Norm node definition: - -type NormNodeProps = { - id: string; - data: normNodeData; -}; - -export const NormNode = ({id, data}: NormNodeProps) => { +/** + * Norm node definition: + * + * @param {string} id + * @param {defaultNodeData & {value: string}} data + * @returns {React.JSX.Element} + * @constructor + */ +export const NormNodeComponent = ({id, data}: NodeProps) => { return ( <> diff --git a/test/pages/visProgPage/visualProgrammingUI/GraphReducer.test.ts b/test/pages/visProgPage/visualProgrammingUI/GraphReducer.test.ts new file mode 100644 index 0000000..a907a58 --- /dev/null +++ b/test/pages/visProgPage/visualProgrammingUI/GraphReducer.test.ts @@ -0,0 +1,986 @@ +import type {Edge} from "@xyflow/react"; +import graphReducer, { + defaultGraphPreprocessor, defaultPhaseReducer, + orderPhases +} from "../../../../src/pages/VisProgPage/visualProgrammingUI/GraphReducer.ts"; +import type {PreparedPhase} from "../../../../src/pages/VisProgPage/visualProgrammingUI/GraphReducerTypes.ts"; +import useFlowStore from "../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx"; +import type {AppNode} from "../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx"; + +// sets of default values for nodes and edges to be used for test cases +type FlowState = { + name: string; + nodes: AppNode[]; + edges: Edge[]; +}; + +// predefined graphs for testing: +const onlyOnePhase : FlowState = { + name: "onlyOnePhase", + nodes: [ + { + id: 'start', + type: 'start', + position: {x: 0, y: 0}, + data: {label: 'start'} + }, + { + id: 'phase-1', + type: 'phase', + position: {x: 0, y: 150}, + data: {label: 'Generic Phase', number: 1}, + }, + { + id: 'end', + type: 'end', + position: {x: 0, y: 300}, + data: {label: 'End'} + } + ], + edges:[ + { + id: 'start-phase-1', + source: 'start', + target: 'phase-1', + }, + { + id: 'phase-1-end', + source: 'phase-1', + target: 'end', + } + ] +}; +const onlyThreePhases : FlowState = { + name: "onlyThreePhases", + nodes: [ + { + id: 'start', + type: 'start', + position: {x: 0, y: 0}, + data: {label: 'start'} + }, + { + id: 'phase-1', + type: 'phase', + position: {x: 0, y: 150}, + data: {label: 'Generic Phase', number: 1}, + }, + { + id: 'phase-3', + type: 'phase', + position: {x: 0, y: 150}, + data: {label: 'Generic Phase', number: 3}, + }, + { + id: 'phase-2', + type: 'phase', + position: {x: 0, y: 150}, + data: {label: 'Generic Phase', number: 2}, + }, + { + id: 'end', + type: 'end', + position: {x: 0, y: 300}, + data: {label: 'End'} + } + ], + edges:[ + { + id: 'start-phase-1', + source: 'start', + target: 'phase-1', + }, + { + id: 'phase-1-phase-2', + source: 'phase-1', + target: 'phase-2', + }, + { + id: 'phase-2-phase-3', + source: 'phase-2', + target: 'phase-3', + }, + { + id: 'phase-3-end', + source: 'phase-3', + target: 'end', + } + ] +}; +const onlySingleEdgeNorms : FlowState = { + name: "onlySingleEdgeNorms", + nodes: [ + { + id: 'start', + type: 'start', + position: {x: 0, y: 0}, + data: {label: 'start'} + }, + { + id: 'phase-1', + type: 'phase', + position: {x: 0, y: 150}, + data: {label: 'Generic Phase', number: 1}, + }, + { + id: 'norm-1', + type: 'norm', + position: {x: 0, y: 150}, + data: {label: 'Generic Norm', value: "generic"}, + }, + { + id: 'norm-2', + type: 'norm', + position: {x: 0, y: 150}, + data: {label: 'Generic Norm', value: "generic"}, + }, + { + id: 'phase-3', + type: 'phase', + position: {x: 0, y: 150}, + data: {label: 'Generic Phase', number: 3}, + }, + { + id: 'phase-2', + type: 'phase', + position: {x: 0, y: 150}, + data: {label: 'Generic Phase', number: 2}, + }, + { + id: 'end', + type: 'end', + position: {x: 0, y: 300}, + data: {label: 'End'} + } + ], + edges:[ + { + id: 'start-phase-1', + source: 'start', + target: 'phase-1', + }, + { + id: 'norm-1-phase-2', + source: 'norm-1', + target: 'phase-2', + }, + { + id: 'phase-1-phase-2', + source: 'phase-1', + target: 'phase-2', + }, + { + id: 'phase-2-phase-3', + source: 'phase-2', + target: 'phase-3', + }, + { + id: 'norm-2-phase-3', + source: 'norm-2', + target: 'phase-3', + }, + { + id: 'phase-3-end', + source: 'phase-3', + target: 'end', + } + ] +}; +const multiEdgeNorms : FlowState = { + name: "multiEdgeNorms", + nodes: [ + { + id: 'start', + type: 'start', + position: {x: 0, y: 0}, + data: {label: 'start'} + }, + { + id: 'phase-1', + type: 'phase', + position: {x: 0, y: 150}, + data: {label: 'Generic Phase', number: 1}, + }, + { + id: 'norm-1', + type: 'norm', + position: {x: 0, y: 150}, + data: {label: 'Generic Norm', value: "generic"}, + }, + { + id: 'norm-2', + type: 'norm', + position: {x: 0, y: 150}, + data: {label: 'Generic Norm', value: "generic"}, + }, + { + id: 'phase-3', + type: 'phase', + position: {x: 0, y: 150}, + data: {label: 'Generic Phase', number: 3}, + }, + { + id: 'norm-3', + type: 'norm', + position: {x: 0, y: 150}, + data: {label: 'Generic Norm', value: "generic"}, + }, + { + id: 'phase-2', + type: 'phase', + position: {x: 0, y: 150}, + data: {label: 'Generic Phase', number: 2}, + }, + { + id: 'end', + type: 'end', + position: {x: 0, y: 300}, + data: {label: 'End'} + } + ], + edges:[ + { + id: 'start-phase-1', + source: 'start', + target: 'phase-1', + }, + { + id: 'norm-1-phase-2', + source: 'norm-1', + target: 'phase-2', + }, + { + id: 'norm-1-phase-3', + source: 'norm-1', + target: 'phase-3', + }, + { + id: 'phase-1-phase-2', + source: 'phase-1', + target: 'phase-2', + }, + { + id: 'norm-3-phase-1', + source: 'norm-3', + target: 'phase-1', + }, + { + id: 'phase-2-phase-3', + source: 'phase-2', + target: 'phase-3', + }, + { + id: 'norm-2-phase-3', + source: 'norm-2', + target: 'phase-3', + }, + { + id: 'norm-2-phase-2', + source: 'norm-2', + target: 'phase-2', + }, + { + id: 'phase-3-end', + source: 'phase-3', + target: 'end', + } + ] +}; +const onlyStartEnd : FlowState = { + name: "onlyStartEnd", + nodes: [ + { + id: 'start', + type: 'start', + position: {x: 0, y: 0}, + data: {label: 'start'} + }, + { + id: 'end', + type: 'end', + position: {x: 0, y: 300}, + data: {label: 'End'} + } + ], + edges:[ + { + id: 'start-end', + source: 'start', + target: 'end', + }, + ] +}; + +// states that contain invalid programs for testing if correct errors are thrown: +const phaseConnectsToInvalidNodeType : FlowState = { + name: "phaseConnectsToInvalidNodeType", + nodes: [ + { + id: 'start', + type: 'start', + position: {x: 0, y: 0}, + data: {label: 'start'} + }, + { + id: 'phase-1', + type: 'phase', + position: {x: 0, y: 150}, + data: {label: 'Generic Phase', number: 1}, + }, + { + id: 'default-1', + type: 'default', + position: {x: 0, y: 150}, + data: {label: 'Generic Norm'}, + }, + { + id: 'end', + type: 'end', + position: {x: 0, y: 300}, + data: {label: 'End'} + } + ], + edges:[ + { + id: 'start-phase-1', + source: 'start', + target: 'phase-1', + }, + { + id: 'phase-1-default-1', + source: 'phase-1', + target: 'default-1', + }, + ] +}; +const phaseHasNoOutgoingConnections : FlowState = { + name: "phaseHasNoOutgoingConnections", + nodes: [ + { + id: 'start', + type: 'start', + position: {x: 0, y: 0}, + data: {label: 'start'} + }, + { + id: 'phase-1', + type: 'phase', + position: {x: 0, y: 150}, + data: {label: 'Generic Phase', number: 1}, + }, + { + id: 'phase-2', + type: 'phase', + position: {x: 0, y: 150}, + data: {label: 'Generic Phase', number: 2}, + }, + { + id: 'end', + type: 'end', + position: {x: 0, y: 300}, + data: {label: 'End'} + } + ], + edges:[ + { + id: 'start-phase-1', + source: 'start', + target: 'phase-1', + }, + ] +}; +const phaseHasTooManyOutgoingConnections : FlowState = { + name: "phaseHasTooManyOutgoingConnections", + nodes: [ + { + id: 'start', + type: 'start', + position: {x: 0, y: 0}, + data: {label: 'start'} + }, + { + id: 'phase-1', + type: 'phase', + position: {x: 0, y: 150}, + data: {label: 'Generic Phase', number: 1}, + }, + { + id: 'phase-2', + type: 'phase', + position: {x: 0, y: 150}, + data: {label: 'Generic Phase', number: 2}, + }, + { + id: 'end', + type: 'end', + position: {x: 0, y: 300}, + data: {label: 'End'} + } + ], + edges:[ + { + id: 'start-phase-1', + source: 'start', + target: 'phase-1', + }, + { + id: 'phase-1-phase-2', + source: 'phase-1', + target: 'phase-2', + }, + { + id: 'phase-1-end', + source: 'phase-1', + target: 'end', + }, + { + id: 'phase-2-end', + source: 'phase-2', + target: 'end', + }, + ] +}; + +describe('Graph Reducer Tests', () => { + describe('defaultGraphPreprocessor', () => { + test.each([ + { + state: onlyOnePhase, + expected: [ + { + phaseNode: { + id: 'phase-1', + type: 'phase', + position: {x: 0, y: 150}, + data: {label: 'Generic Phase', number: 1}, + }, + nextPhaseId: 'end', + connectedNorms: [], + connectedGoals: [], + }] + }, + { + state: onlyThreePhases, + expected: [ + { + phaseNode: { + id: 'phase-1', + type: 'phase', + position: {x: 0, y: 150}, + data: {label: 'Generic Phase', number: 1}, + }, + nextPhaseId: 'phase-2', + connectedNorms: [], + connectedGoals: [], + }, + { + phaseNode: { + id: 'phase-2', + type: 'phase', + position: {x: 0, y: 150}, + data: {label: 'Generic Phase', number: 2}, + }, + nextPhaseId: 'phase-3', + connectedNorms: [], + connectedGoals: [], + }, + { + phaseNode: { + id: 'phase-3', + type: 'phase', + position: {x: 0, y: 150}, + data: {label: 'Generic Phase', number: 3}, + }, + nextPhaseId: 'end', + connectedNorms: [], + connectedGoals: [], + }] + }, + { + state: onlySingleEdgeNorms, + expected: [ + { + phaseNode: { + id: 'phase-1', + type: 'phase', + position: {x: 0, y: 150}, + data: {label: 'Generic Phase', number: 1}, + }, + nextPhaseId: 'phase-2', + connectedNorms: [], + connectedGoals: [], + }, + { + phaseNode: { + id: 'phase-2', + type: 'phase', + position: {x: 0, y: 150}, + data: {label: 'Generic Phase', number: 2}, + }, + nextPhaseId: 'phase-3', + connectedNorms: [{ + id: 'norm-1', + type: 'norm', + position: {x: 0, y: 150}, + data: {label: 'Generic Norm', value: "generic"}, + }], + connectedGoals: [], + }, + { + phaseNode: { + id: 'phase-3', + type: 'phase', + position: {x: 0, y: 150}, + data: {label: 'Generic Phase', number: 3}, + }, + nextPhaseId: 'end', + connectedNorms: [{ + id: 'norm-2', + type: 'norm', + position: {x: 0, y: 150}, + data: {label: 'Generic Norm', value: "generic"}, + }], + connectedGoals: [], + }] + }, + { + state: multiEdgeNorms, + expected: [ + { + phaseNode: { + id: 'phase-1', + type: 'phase', + position: {x: 0, y: 150}, + data: {label: 'Generic Phase', number: 1}, + }, + nextPhaseId: 'phase-2', + connectedNorms: [{ + id: 'norm-3', + type: 'norm', + position: {x: 0, y: 150}, + data: {label: 'Generic Norm', value: "generic"}, + }], + connectedGoals: [], + }, + { + phaseNode: { + id: 'phase-2', + type: 'phase', + position: {x: 0, y: 150}, + data: {label: 'Generic Phase', number: 2}, + }, + nextPhaseId: 'phase-3', + connectedNorms: [{ + id: 'norm-1', + type: 'norm', + position: {x: 0, y: 150}, + data: {label: 'Generic Norm', value: "generic"}, + }, + { + id: 'norm-2', + type: 'norm', + position: {x: 0, y: 150}, + data: {label: 'Generic Norm', value: "generic"}, + }], + connectedGoals: [], + }, + { + phaseNode: { + id: 'phase-3', + type: 'phase', + position: {x: 0, y: 150}, + data: {label: 'Generic Phase', number: 3}, + }, + nextPhaseId: 'end', + connectedNorms: [{ + id: 'norm-1', + type: 'norm', + position: {x: 0, y: 150}, + data: {label: 'Generic Norm', value: "generic"}, + }, + { + id: 'norm-2', + type: 'norm', + position: {x: 0, y: 150}, + data: {label: 'Generic Norm', value: "generic"}, + }], + connectedGoals: [], + }] + }, + { + state: onlyStartEnd, + expected: [], + } + ])(`tests state: $state.name`, ({state, expected}) => { + const output = defaultGraphPreprocessor(state.nodes, state.edges); + expect(output).toEqual(expected); + }); + }); + describe("orderPhases", () => { + test.each([ + { + state: onlyOnePhase, + expected: { + phaseNodes: [{ + id: 'phase-1', + type: 'phase', + position: {x: 0, y: 150}, + data: {label: 'Generic Phase', number: 1}, + }], + connections: new Map([["phase-1","end"]]) + } + }, + { + state: onlyThreePhases, + expected: { + phaseNodes: [ + { + id: 'phase-1', + type: 'phase', + position: {x: 0, y: 150}, + data: {label: 'Generic Phase', number: 1}, + }, + { + id: 'phase-2', + type: 'phase', + position: {x: 0, y: 150}, + data: {label: 'Generic Phase', number: 2}, + }, + { + id: 'phase-3', + type: 'phase', + position: {x: 0, y: 150}, + data: {label: 'Generic Phase', number: 3}, + }], + connections: new Map([ + ["phase-1","phase-2"], + ["phase-2","phase-3"], + ["phase-3","end"] + ]) + } + }, + { + state: onlySingleEdgeNorms, + expected: { + phaseNodes: [ + { + id: 'phase-1', + type: 'phase', + position: {x: 0, y: 150}, + data: {label: 'Generic Phase', number: 1}, + }, + { + id: 'phase-2', + type: 'phase', + position: {x: 0, y: 150}, + data: {label: 'Generic Phase', number: 2}, + }, + { + id: 'phase-3', + type: 'phase', + position: {x: 0, y: 150}, + data: {label: 'Generic Phase', number: 3}, + }], + connections: new Map([ + ["phase-1","phase-2"], + ["phase-2","phase-3"], + ["phase-3","end"] + ]) + } + }, + { + state: onlyStartEnd, + expected: { + phaseNodes: [], + connections: new Map() + } + } + ])(`tests state: $state.name`, ({state, expected}) => { + const output = orderPhases(state.nodes, state.edges); + expect(output.phaseNodes).toEqual(expected.phaseNodes); + expect(output.connections).toEqual(expected.connections); + }); + test.each([ + { + state: phaseConnectsToInvalidNodeType, + expected: new Error('| INVALID PROGRAM | the node "default-1" that "phase-1" connects to is not a phase or end node') + }, + { + state: phaseHasNoOutgoingConnections, + expected: new Error('| INVALID PROGRAM | the source handle of "phase-1" doesn\'t have any outgoing connections') + }, + { + state: phaseHasTooManyOutgoingConnections, + expected: new Error('| INVALID PROGRAM | the source handle of "phase-1" connects to too many targets') + } + ])(`tests erroneous state: $state.name`, ({state, expected}) => { + const testForError = () => { + orderPhases(state.nodes, state.edges); + }; + expect(testForError).toThrow(expected); + }) + }) + describe("defaultPhaseReducer", () => { + test("phaseReducer handles empty norms and goals without failing", () => { + const input : PreparedPhase = { + phaseNode: { + id: 'phase-1', + type: 'phase', + position: {x: 0, y: 150}, + data: {label: 'Generic Phase', number: 1}, + }, + nextPhaseId: 'end', + connectedNorms: [], + connectedGoals: [], + } + const output = defaultPhaseReducer(input); + expect(output).toEqual({ + id: 'phase-1', + name: 'Generic Phase', + nextPhaseId: 'end', + phaseData: { + norms: [], + goals: [] + } + }); + }); + test("defaultNormReducer reduces norms correctly", () => { + const input : PreparedPhase = { + phaseNode: { + id: 'phase-1', + type: 'phase', + position: {x: 0, y: 150}, + data: {label: 'Generic Phase', number: 1}, + }, + nextPhaseId: 'end', + connectedNorms: [{ + id: 'norm-1', + type: 'norm', + position: {x: 0, y: 150}, + data: {label: 'Generic Norm', value: "generic"}, + }], + connectedGoals: [], + } + const output = defaultPhaseReducer(input); + expect(output).toEqual({ + id: 'phase-1', + name: 'Generic Phase', + nextPhaseId: 'end', + phaseData: { + norms: [{ + id: 'norm-1', + name: 'Generic Norm', + value: "generic" + }], + goals: [] + } + }); + }); + test("defaultGoalReducer reduces goals correctly", () => { + const input : PreparedPhase = { + phaseNode: { + id: 'phase-1', + type: 'phase', + position: {x: 0, y: 150}, + data: {label: 'Generic Phase', number: 1}, + }, + nextPhaseId: 'end', + connectedNorms: [], + connectedGoals: [{ + id: 'goal-1', + type: 'goal', + position: {x: 0, y: 150}, + data: {label: 'Generic Goal', value: "generic"}, + }], + } + const output = defaultPhaseReducer(input); + expect(output).toEqual({ + id: 'phase-1', + name: 'Generic Phase', + nextPhaseId: 'end', + phaseData: { + norms: [], + goals: [{ + id: 'goal-1', + name: 'Generic Goal', + value: "generic" + }] + } + }); + }); + }) + describe("GraphReducer", () => { + test.each([ + { + state: onlyOnePhase, + expected: [ + { + id: 'phase-1', + name: 'Generic Phase', + nextPhaseId: 'end', + phaseData: { + norms: [], + goals: [] + } + }] + }, + { + state: onlyThreePhases, + expected: [ + { + id: 'phase-1', + name: 'Generic Phase', + nextPhaseId: 'phase-2', + phaseData: { + norms: [], + goals: [] + } + }, + { + id: 'phase-2', + name: 'Generic Phase', + nextPhaseId: 'phase-3', + phaseData: { + norms: [], + goals: [] + } + }, + { + id: 'phase-3', + name: 'Generic Phase', + nextPhaseId: 'end', + phaseData: { + norms: [], + goals: [] + } + }] + }, + { + state: onlySingleEdgeNorms, + expected: [ + { + id: 'phase-1', + name: 'Generic Phase', + nextPhaseId: 'phase-2', + phaseData: { + norms: [], + goals: [] + } + }, + { + id: 'phase-2', + name: 'Generic Phase', + nextPhaseId: 'phase-3', + phaseData: { + norms: [ + { + id: 'norm-1', + name: 'Generic Norm', + value: "generic" + } + ], + goals: [] + } + }, + { + id: 'phase-3', + name: 'Generic Phase', + nextPhaseId: 'end', + phaseData: { + norms: [{ + id: 'norm-2', + name: 'Generic Norm', + value: "generic" + }], + goals: [] + } + }] + }, + { + state: multiEdgeNorms, + expected: [ + { + id: 'phase-1', + name: 'Generic Phase', + nextPhaseId: 'phase-2', + phaseData: { + norms: [{ + id: 'norm-3', + name: 'Generic Norm', + value: "generic" + }], + goals: [] + } + }, + { + id: 'phase-2', + name: 'Generic Phase', + nextPhaseId: 'phase-3', + phaseData: { + norms: [ + { + id: 'norm-1', + name: 'Generic Norm', + value: "generic" + }, + { + id: 'norm-2', + name: 'Generic Norm', + value: "generic" + } + ], + goals: [] + } + }, + { + id: 'phase-3', + name: 'Generic Phase', + nextPhaseId: 'end', + phaseData: { + norms: [{ + id: 'norm-1', + name: 'Generic Norm', + value: "generic" + }, + { + id: 'norm-2', + name: 'Generic Norm', + value: "generic" + }], + goals: [] + } + }] + }, + { + state: onlyStartEnd, + expected: [], + } + ])("`tests state: $state.name`", ({state, expected}) => { + useFlowStore.setState({nodes: state.nodes, edges: state.edges}); + const output = graphReducer(); // uses default reducers + expect(output).toEqual(expected); + }) + // we run the test for correct error handling for the entire graph reducer as well, + // to make sure no errors occur before we intend to handle the errors ourselves + test.each([ + { + state: phaseConnectsToInvalidNodeType, + expected: new Error('| INVALID PROGRAM | the node "default-1" that "phase-1" connects to is not a phase or end node') + }, + { + state: phaseHasNoOutgoingConnections, + expected: new Error('| INVALID PROGRAM | the source handle of "phase-1" doesn\'t have any outgoing connections') + }, + { + state: phaseHasTooManyOutgoingConnections, + expected: new Error('| INVALID PROGRAM | the source handle of "phase-1" connects to too many targets') + } + ])(`tests erroneous state: $state.name`, ({state, expected}) => { + useFlowStore.setState({nodes: state.nodes, edges: state.edges}); + const testForError = () => { + graphReducer(); + }; + expect(testForError).toThrow(expected); + }) + }) +}); \ No newline at end of file diff --git a/test/pages/visProgPage/visualProgrammingUI/components/DragDropSidebar.test.tsx b/test/pages/visProgPage/visualProgrammingUI/components/DragDropSidebar.test.tsx index ae9b88c..a92adb3 100644 --- a/test/pages/visProgPage/visualProgrammingUI/components/DragDropSidebar.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/components/DragDropSidebar.test.tsx @@ -24,8 +24,8 @@ describe('Drag-and-Drop sidebar', () => { }) const updatedState = useFlowStore.getState(); expect(updatedState.nodes.length).toBe(2); - expect(updatedState.nodes[0].id).toBe(`${nodeType}-0`); - expect(updatedState.nodes[1].id).toBe(`${nodeType}-1`); + expect(updatedState.nodes[0].id).toBe(`${nodeType}-1`); + expect(updatedState.nodes[1].id).toBe(`${nodeType}-2`); }); test('throws error on unexpected node type', () => { expect(() => addNode('I do not Exist', {x:100, y:100})).toThrow("Node I do not Exist not found"); -- 2.49.1 From 1b8095376b31027fd7bc39199c9d8c96d4e9ba69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Wed, 5 Nov 2025 17:21:36 +0100 Subject: [PATCH 086/184] fix: fixed npx eslint (also accounting for justins part) ref: N25B-142 --- src/visualProgrammingUI/components/DragDropSidebar.tsx | 3 +-- test/pages/connectedRobots/ConnectedRobots.test.tsx | 4 +++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/visualProgrammingUI/components/DragDropSidebar.tsx b/src/visualProgrammingUI/components/DragDropSidebar.tsx index b3926d9..f1bf6bd 100644 --- a/src/visualProgrammingUI/components/DragDropSidebar.tsx +++ b/src/visualProgrammingUI/components/DragDropSidebar.tsx @@ -27,8 +27,7 @@ function DraggableNode({ className, children, nodeType, onDrop }: DraggableNodeP const draggableRef = useRef(null); const [position, setPosition] = useState({ x: 0, y: 0 }); - - // @ts-ignore + // @ts-expect-error we expect the null referece here. useDraggable(draggableRef, { position: position, onDrag: ({ offsetX, offsetY }) => { diff --git a/test/pages/connectedRobots/ConnectedRobots.test.tsx b/test/pages/connectedRobots/ConnectedRobots.test.tsx index c2d3749..017b2a2 100644 --- a/test/pages/connectedRobots/ConnectedRobots.test.tsx +++ b/test/pages/connectedRobots/ConnectedRobots.test.tsx @@ -25,7 +25,9 @@ class MockEventSource { // mock event source generation with fake function that returns our fake mock source beforeAll(() => { - (globalThis as any).EventSource = jest.fn((url: string) => new MockEventSource(url)); + // Cast globalThis to a type exposing EventSource and assign a mocked constructor. + (globalThis as unknown as { EventSource?: typeof EventSource }).EventSource = + jest.fn((url: string) => new MockEventSource(url)) as unknown as typeof EventSource; }); // clean after tests -- 2.49.1 From 8733bb3c040706f301ba08a9c042257e254577cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Tue, 11 Nov 2025 10:25:27 +0100 Subject: [PATCH 087/184] chore: remove old remnants from project --- src/visualProgrammingUI/VisProgUI.css | 7 - src/visualProgrammingUI/VisProgUI.tsx | 132 ----------------- .../components/DragDropSidebar.tsx | 140 ------------------ .../components/NodeDefinitions.tsx | 111 -------------- 4 files changed, 390 deletions(-) delete mode 100644 src/visualProgrammingUI/VisProgUI.css delete mode 100644 src/visualProgrammingUI/VisProgUI.tsx delete mode 100644 src/visualProgrammingUI/components/DragDropSidebar.tsx delete mode 100644 src/visualProgrammingUI/components/NodeDefinitions.tsx diff --git a/src/visualProgrammingUI/VisProgUI.css b/src/visualProgrammingUI/VisProgUI.css deleted file mode 100644 index 8d82d09..0000000 --- a/src/visualProgrammingUI/VisProgUI.css +++ /dev/null @@ -1,7 +0,0 @@ -.default-node { - padding: 10px 20px; - background-color: canvas; - outline-style: solid; - border-radius: 5pt; - outline-width: 1pt; -} \ No newline at end of file diff --git a/src/visualProgrammingUI/VisProgUI.tsx b/src/visualProgrammingUI/VisProgUI.tsx deleted file mode 100644 index a2b1e9c..0000000 --- a/src/visualProgrammingUI/VisProgUI.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import './VisProgUI.css' - -import { - useCallback, - useRef -} from 'react'; -import { - Background, - Controls, - ReactFlow, - ReactFlowProvider, - useNodesState, - useEdgesState, - reconnectEdge, - addEdge, - MarkerType, - type Edge, - type Connection, -} from '@xyflow/react'; -import '@xyflow/react/dist/style.css'; -import { - StartNode, - EndNode, - PhaseNode, - NormNode -} from "./components/NodeDefinitions.tsx"; - -import { Sidebar } from './components/DragDropSidebar.tsx'; - -const nodeTypes = { - start: StartNode, - end: EndNode, - phase: PhaseNode, - norm: NormNode -}; - -const initialNodes = [ - { - id: 'start', - type: 'start', - position: {x: 0, y: 0}, - data: {label: 'start'} - }, - { - id: 'genericPhase', - type: 'phase', - position: {x: 0, y: 150}, - data: {label: 'Generic Phase', number: 1}, - }, - { - id: 'end', - type: 'end', - position: {x: 0, y: 300}, - data: {label: 'End'} - } -]; -const initialEdges = [{id: 'start-end', source: 'start', target: 'end'}]; - -const defaultEdgeOptions = { - type: 'floating', - markerEnd: { - type: MarkerType.ArrowClosed, - color: '#505050', - }, -}; - -const VisProgUI = ()=> { - const edgeReconnectSuccessful = useRef(true); - const [nodes, , onNodesChange] = useNodesState(initialNodes); - const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); - - const onConnect = useCallback( - (params: Edge | Connection) => setEdges((els) => addEdge(params, els)), - [setEdges], - ); - - const onReconnectStart = useCallback(() => { - edgeReconnectSuccessful.current = false; - }, []); - - const onReconnect = useCallback((oldEdge: Edge, newConnection: Connection) => { - edgeReconnectSuccessful.current = true; - setEdges((els) => reconnectEdge(oldEdge, newConnection, els)); - }, [setEdges]); - - const onReconnectEnd = useCallback((_: unknown, edge: { id: string; }) => { - if (!edgeReconnectSuccessful.current) { - setEdges((eds) => eds.filter((e) => e.id !== edge.id)); - } - - edgeReconnectSuccessful.current = true; - }, [setEdges]); - - return ( -
-
- - - - -
-
- -
-
- - ); -}; - -function VisualProgrammingUI(){ - return ( - - - - ); -} - -export default VisualProgrammingUI; \ No newline at end of file diff --git a/src/visualProgrammingUI/components/DragDropSidebar.tsx b/src/visualProgrammingUI/components/DragDropSidebar.tsx deleted file mode 100644 index f1bf6bd..0000000 --- a/src/visualProgrammingUI/components/DragDropSidebar.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import { useDraggable } from '@neodrag/react'; -import { - useReactFlow, - type XYPosition -} from '@xyflow/react'; -import { - type ReactNode, - useCallback, - useRef, - useState -} from 'react'; - - -// improve later to create better automatic IDs -let id = 0; -const getId = () => `dndnode_${id++}`; - - -interface DraggableNodeProps { - className?: string; - children: ReactNode; - nodeType: string; - onDrop: (nodeType: string, position: XYPosition) => void; -} - -function DraggableNode({ className, children, nodeType, onDrop }: DraggableNodeProps) { - const draggableRef = useRef(null); - const [position, setPosition] = useState({ x: 0, y: 0 }); - - // @ts-expect-error we expect the null referece here. - useDraggable(draggableRef, { - position: position, - onDrag: ({ offsetX, offsetY }) => { - // Calculate position relative to the viewport - setPosition({ - x: offsetX, - y: offsetY, - }); - }, - onDragEnd: ({ event }) => { - setPosition({ x: 0, y: 0 }); - onDrop(nodeType, { - x: event.clientX, - y: event.clientY, - }); - }, - }); - - return ( -
- {children} -
- ); -} - -export function Sidebar() { - const { setNodes, screenToFlowPosition } = useReactFlow(); - - const handleNodeDrop = useCallback( - (nodeType: string, screenPosition: XYPosition) => { - const flow = document.querySelector('.react-flow'); - const flowRect = flow?.getBoundingClientRect(); - const isInFlow = - flowRect && - screenPosition.x >= flowRect.left && - screenPosition.x <= flowRect.right && - screenPosition.y >= flowRect.top && - screenPosition.y <= flowRect.bottom; - - // Create a new node and add it to the flow - if (isInFlow) { - const position = screenToFlowPosition(screenPosition); - - const newNode = () => { - switch (nodeType) { - case "phase": - return { - id: getId(), - type: nodeType, - position, - data: {label: `"new"`, number: (-1)}, - }; - case "start": - return { - id: getId(), - type: nodeType, - position, - data: {label: `new start node`}, - }; - case "end": - return { - id: getId(), - type: nodeType, - position, - data: {label: `new end node`}, - }; - case "norm": - return { - id: getId(), - type: nodeType, - position, - data: {label: `new norm node`}, - }; - default: { - return { - id: getId(), - type: nodeType, - position, - data: {label: `new default node`}, - }; - } - } - } - - setNodes((nds) => nds.concat(newNode())); - } - }, - [setNodes, screenToFlowPosition], - ); - - return ( - - ); -} \ No newline at end of file diff --git a/src/visualProgrammingUI/components/NodeDefinitions.tsx b/src/visualProgrammingUI/components/NodeDefinitions.tsx deleted file mode 100644 index b4547b2..0000000 --- a/src/visualProgrammingUI/components/NodeDefinitions.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import {Handle, NodeToolbar, Position, useReactFlow} from '@xyflow/react'; -import '@xyflow/react/dist/style.css'; -import '../VisProgUI.css'; - -// Datatypes for NodeTypes - -type defaultNodeData = { - label: string; -}; - -type startNodeData = defaultNodeData; -type endNodeData = defaultNodeData; -type normNodeData = defaultNodeData; -type phaseNodeData = defaultNodeData & { - number: number; -}; - -export type nodeData = defaultNodeData | startNodeData | phaseNodeData | endNodeData; - -// Node Toolbar definition - -type ToolbarProps= { - nodeId: string; -}; - -export function Toolbar({nodeId}:ToolbarProps) { - const { setNodes, setEdges } = useReactFlow(); - - const handleDelete = () => { - setNodes((nds) => nds.filter((n) => n.id !== nodeId)); - setEdges((eds) => eds.filter((e) => e.source !== nodeId && e.target !== nodeId)); - }; - return ( - - - ); -} - - -// Definitions of Nodes - -type StartNodeProps = { - id: string; - data: startNodeData; -}; - -export const StartNode= ({ id, data }: StartNodeProps) => { - return ( - <> - -
-
data test {data.label}
- -
- - ); -}; - -type EndNodeProps = { - id: string; - data: endNodeData; -}; - -export const EndNode= ({ id, data }: EndNodeProps) => { - return ( - <> - -
-
{data.label}
- -
- - ); -}; - - -type PhaseNodeProps = { - id: string; - data: phaseNodeData; -}; - -export const PhaseNode= ({ id, data }: PhaseNodeProps) => { - return ( - <> - -
-
phase {data.number} {data.label}
- - - -
- - ); -}; - -type NormNodeProps = { - id: string; - data: normNodeData; -}; - -export const NormNode= ({ id, data }: NormNodeProps) => { - return ( - <> - -
-
Norm {data.label}
- -
- - ); -}; \ No newline at end of file -- 2.49.1 From df4346150e2bf6a81d9629cdbc48884572bae3a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Tue, 11 Nov 2025 11:11:46 +0100 Subject: [PATCH 088/184] chore: remove old code pt 2 --- src/assets/data.ts | 361 --------------------------- src/pages/Logging/Logging.module.css | 17 -- src/pages/Logging/Logging.tsx | 78 ------ 3 files changed, 456 deletions(-) delete mode 100644 src/assets/data.ts delete mode 100644 src/pages/Logging/Logging.module.css delete mode 100644 src/pages/Logging/Logging.tsx diff --git a/src/assets/data.ts b/src/assets/data.ts deleted file mode 100644 index c1eacfb..0000000 --- a/src/assets/data.ts +++ /dev/null @@ -1,361 +0,0 @@ -export const DATA: LogEntry[] = [ - { - id: "1", - timestamp: "2025-10-01T12:00:00Z", - level: "info", - msg: "User said: Hello, Pepper!", - type: "speech", - }, - { - id: "2", - timestamp: "2025-10-01T12:00:05Z", - level: "debug", - msg: "Proximity sensor value: 0.85", - type: "sensor", - }, - { - id: "3", - timestamp: "2025-10-01T12:00:10Z", - level: "warn", - msg: "Battery level low: 15%", - type: "system", - }, - { - id: "4", - timestamp: "2025-10-01T12:00:15Z", - level: "info", - msg: "User requested weather update.", - type: "speech", - }, - { - id: "5", - timestamp: "2025-10-01T12:00:20Z", - level: "debug", - msg: "Microphone activated.", - type: "system", - }, - { - id: "6", - timestamp: "2025-10-01T12:00:25Z", - level: "warn", - msg: "Obstacle detected in front.", - type: "sensor", - }, - { - id: "7", - timestamp: "2025-10-01T12:00:30Z", - level: "info", - msg: "User said: Thank you!", - type: "speech", - }, - { - id: "8", - timestamp: "2025-10-01T12:00:35Z", - level: "debug", - msg: "Network latency: 120ms", - type: "system", - }, - { - id: "9", - timestamp: "2025-10-01T12:00:40Z", - level: "warn", - msg: "High CPU usage detected.", - type: "system", - }, - { - id: "10", - timestamp: "2025-10-01T12:00:45Z", - level: "info", - msg: "User started a new session.", - type: "system", - }, - { - id: "11", - timestamp: "2025-10-01T12:01:00Z", - level: "info", - msg: "User asked: What's the weather?", - type: "speech", - }, - { - id: "12", - timestamp: "2025-10-01T12:01:05Z", - level: "debug", - msg: "Camera initialized.", - type: "system", - }, - { - id: "13", - timestamp: "2025-10-01T12:01:10Z", - level: "warn", - msg: "Temperature sensor disconnected.", - type: "sensor", - }, - { - id: "14", - timestamp: "2025-10-01T12:01:15Z", - level: "info", - msg: "User said: Play some music.", - type: "speech", - }, - { - id: "15", - timestamp: "2025-10-01T12:01:20Z", - level: "debug", - msg: "Audio output device selected: Speaker.", - type: "system", - }, - { - id: "16", - timestamp: "2025-10-01T12:01:25Z", - level: "warn", - msg: "Low light detected in room.", - type: "sensor", - }, - { - id: "17", - timestamp: "2025-10-01T12:01:30Z", - level: "info", - msg: "User said: Turn on the lights.", - type: "speech", - }, - { - id: "18", - timestamp: "2025-10-01T12:01:35Z", - level: "debug", - msg: "Light control signal sent.", - type: "system", - }, - { - id: "19", - timestamp: "2025-10-01T12:01:40Z", - level: "warn", - msg: "Light bulb not responding.", - type: "system", - }, - { - id: "20", - timestamp: "2025-10-01T12:01:45Z", - level: "info", - msg: "User said: Good night.", - type: "speech", - }, - { - id: "21", - timestamp: "2025-10-01T12:02:00Z", - level: "info", - msg: "User asked: What's the time?", - type: "speech", - }, - { - id: "22", - timestamp: "2025-10-01T12:02:05Z", - level: "debug", - msg: "Time module loaded.", - type: "system", - }, - { - id: "23", - timestamp: "2025-10-01T12:02:10Z", - level: "warn", - msg: "WiFi signal weak.", - type: "system", - }, - { - id: "24", - timestamp: "2025-10-01T12:02:15Z", - level: "info", - msg: "User said: Set an alarm for 7 AM.", - type: "speech", - }, - { - id: "25", - timestamp: "2025-10-01T12:02:20Z", - level: "debug", - msg: "Alarm scheduled for 7:00 AM.", - type: "system", - }, - { - id: "26", - timestamp: "2025-10-01T12:02:25Z", - level: "warn", - msg: "Alarm module not responding.", - type: "system", - }, - { - id: "27", - timestamp: "2025-10-01T12:02:30Z", - level: "info", - msg: "User said: Cancel the alarm.", - type: "speech", - }, - { - id: "28", - timestamp: "2025-10-01T12:02:35Z", - level: "debug", - msg: "Alarm cancellation requested.", - type: "system", - }, - { - id: "29", - timestamp: "2025-10-01T12:02:40Z", - level: "warn", - msg: "Alarm cancellation failed.", - type: "system", - }, - { - id: "30", - timestamp: "2025-10-01T12:02:45Z", - level: "info", - msg: "User said: Open the window.", - type: "speech", - }, - { - id: "31", - timestamp: "2025-10-01T12:03:00Z", - level: "info", - msg: "User asked: What's on my calendar?", - type: "speech", - }, - { - id: "32", - timestamp: "2025-10-01T12:03:05Z", - level: "debug", - msg: "Calendar module loaded.", - type: "system", - }, - { - id: "33", - timestamp: "2025-10-01T12:03:10Z", - level: "warn", - msg: "Calendar sync failed.", - type: "system", - }, - { - id: "34", - timestamp: "2025-10-01T12:03:15Z", - level: "info", - msg: "User said: Remind me to call John.", - type: "speech", - }, - { - id: "35", - timestamp: "2025-10-01T12:03:20Z", - level: "debug", - msg: "Reminder set for John.", - type: "system", - }, - { - id: "36", - timestamp: "2025-10-01T12:03:25Z", - level: "warn", - msg: "Reminder module not available.", - type: "system", - }, - { - id: "37", - timestamp: "2025-10-01T12:03:30Z", - level: "info", - msg: "User said: What's the news?", - type: "speech", - }, - { - id: "38", - timestamp: "2025-10-01T12:03:35Z", - level: "debug", - msg: "News API request sent.", - type: "system", - }, - { - id: "39", - timestamp: "2025-10-01T12:03:40Z", - level: "warn", - msg: "News API rate limit reached.", - type: "system", - }, - { - id: "40", - timestamp: "2025-10-01T12:03:45Z", - level: "info", - msg: "User said: Tell me a joke.", - type: "speech", - }, - { - id: "41", - timestamp: "2025-10-01T12:04:00Z", - level: "info", - msg: "User asked: What's the temperature?", - type: "speech", - }, - { - id: "42", - timestamp: "2025-10-01T12:04:05Z", - level: "debug", - msg: "Temperature sensor reading: 22°C.", - type: "sensor", - }, - { - id: "43", - timestamp: "2025-10-01T12:04:10Z", - level: "warn", - msg: "Temperature sensor calibration needed.", - type: "sensor", - }, - { - id: "44", - timestamp: "2025-10-01T12:04:15Z", - level: "info", - msg: "User said: Start cleaning.", - type: "speech", - }, - { - id: "45", - timestamp: "2025-10-01T12:04:20Z", - level: "debug", - msg: "Vacuum motor started.", - type: "system", - }, - { - id: "46", - timestamp: "2025-10-01T12:04:25Z", - level: "warn", - msg: "Vacuum bin full.", - type: "system", - }, - { - id: "47", - timestamp: "2025-10-01T12:04:30Z", - level: "info", - msg: "User said: Stop cleaning.", - type: "speech", - }, - { - id: "48", - timestamp: "2025-10-01T12:04:35Z", - level: "debug", - msg: "Vacuum motor stopped.", - type: "system", - }, - { - id: "49", - timestamp: "2025-10-01T12:04:40Z", - level: "warn", - msg: "Obstacle detected during cleaning.", - type: "sensor", - }, - { - id: "50", - timestamp: "2025-10-01T12:04:45Z", - level: "info", - msg: "User said: Goodbye!", - type: "speech", - }, -]; - -interface LogEntry { - id: string; - type?: string; - timestamp: string; - level: "info" | "debug" | "warn"; - msg?: string; -} - diff --git a/src/pages/Logging/Logging.module.css b/src/pages/Logging/Logging.module.css deleted file mode 100644 index 52191c3..0000000 --- a/src/pages/Logging/Logging.module.css +++ /dev/null @@ -1,17 +0,0 @@ -.DivToScroll{ - background-color: color-mix(in srgb, canvas, #000 5%); - border: 1px solid color-mix(in srgb, canvas, #000 15%); - border-radius: 4px 0 4px 0; - color: #3B3C3E; - font-size: 12px; - font-weight: bold; - left: -1px; - padding: 10px 7px 5px; -} - -.DivWithScroll{ - height:50vh; - width:100vh; - overflow:scroll; - overflow-x:hidden; -} diff --git a/src/pages/Logging/Logging.tsx b/src/pages/Logging/Logging.tsx deleted file mode 100644 index 31945c1..0000000 --- a/src/pages/Logging/Logging.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import { useState } from 'react'; -import { DATA } from "../../assets/data"; -import styles from './Logging.module.css'; - - -// const dataType = DATA as { id: string; level: "debug"|"info"|"warn"|"error"; msg: string; timestamp?: string }; -type Level = "debug" | "info" | "warn" | "error"; - -// make optional fields optional -type LogEntry = { - id: string; - level: Level; - timestamp?: string; - msg?: string; - type?: "speech" | "sensor" | "system" | string; - -}; - -function getLevelColor(level: Level) { - switch (level) { - case "debug": - return "gray"; - case "info": - return "blue"; - case "warn": - return "red"; - case "error": - return "red"; - default: - return "black"; - } -} - -function Logging() { - const [logs, setLogs] = useState([]); - - const logDiv = ( -
-
- {logs.map((log) => ( -
- - [{log.timestamp}] - - - {log.msg ? log.msg : "No message"} - - - ({log.level}) - -
- ))} -
-
- ) - return ( - <> -

Log Screen

- { logDiv } -
- - -
- - ) -} - -export default Logging -- 2.49.1 From 87cf723c95d2f0e235a53ce6d2d42273f80f8dea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Tue, 11 Nov 2025 11:42:28 +0100 Subject: [PATCH 089/184] chore: fixed merge request suggestion for adding depency array --- src/pages/ConnectedRobots/ConnectedRobots.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/pages/ConnectedRobots/ConnectedRobots.tsx b/src/pages/ConnectedRobots/ConnectedRobots.tsx index 8b4b898..b7ec65f 100644 --- a/src/pages/ConnectedRobots/ConnectedRobots.tsx +++ b/src/pages/ConnectedRobots/ConnectedRobots.tsx @@ -4,8 +4,6 @@ export default function ConnectedRobots() { const [connected, setConnected] = useState(null); - - useEffect(() => { // We're excepting a stream of data like that looks like this: `data = False` or `data = True` const eventSource = new EventSource("http://localhost:8000/robot/ping_stream"); @@ -29,7 +27,7 @@ export default function ConnectedRobots() { } }; return () => eventSource.close(); - }); + }, []); return (
-- 2.49.1 From d4d1aecb8c6c286b48d83eb72e766eddb026649e Mon Sep 17 00:00:00 2001 From: "Gerla, J. (Justin)" Date: Tue, 11 Nov 2025 13:50:45 +0000 Subject: [PATCH 090/184] feat: added basic functionality for editable name bar --- src/pages/VisProgPage/VisProg.module.css | 41 +++--- .../visualProgrammingUI/VisProgStores.tsx | 134 +++++++++++------- .../visualProgrammingUI/VisProgTypes.tsx | 1 + .../components/NodeDefinitions.tsx | 71 ++++++++-- .../visualProgrammingUI/GraphReducer.test.ts | 2 +- .../VisProgStores.test.tsx | 128 +++++++++++++++++ 6 files changed, 298 insertions(+), 79 deletions(-) diff --git a/src/pages/VisProgPage/VisProg.module.css b/src/pages/VisProgPage/VisProg.module.css index f2f90c7..c58d0f3 100644 --- a/src/pages/VisProgPage/VisProg.module.css +++ b/src/pages/VisProgPage/VisProg.module.css @@ -19,7 +19,28 @@ +.node-text-input { + border: 1px solid transparent; + border-radius: 5pt; + padding: 4px 8px; + outline: none; + background-color: white; + transition: border-color 0.2s, box-shadow 0.2s; + cursor: text; +} +.node-text-input:focus { + border-color: gainsboro; +} + +.node-text-input:read-only { + cursor: pointer; + background-color: whitesmoke; +} + +.node-text-input:read-only:hover { + border-color: gainsboro; +} .dnd-panel { margin-inline-start: auto; @@ -55,34 +76,22 @@ filter: drop-shadow(0 0 0.75rem black); } -.default-node-norm { - padding: 10px 15px; - background-color: canvas; - border-radius: 5pt; +.node-norm { outline: forestgreen solid 2pt; filter: drop-shadow(0 0 0.25rem forestgreen); } -.default-node-phase { - padding: 10px 15px; - background-color: canvas; - border-radius: 5pt; +.node-phase { outline: dodgerblue solid 2pt; filter: drop-shadow(0 0 0.25rem dodgerblue); } -.default-node-start { - padding: 10px 15px; - background-color: canvas; - border-radius: 5pt; +.node-start { outline: orange solid 2pt; filter: drop-shadow(0 0 0.25rem orange); } -.default-node-end { - padding: 10px 15px; - background-color: canvas; - border-radius: 5pt; +.node-end { outline: red solid 2pt; filter: drop-shadow(0 0 0.25rem red); } diff --git a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx index e27fb28..300c14b 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx @@ -6,7 +6,7 @@ import { reconnectEdge, type Edge, type Connection } from '@xyflow/react'; -import {type FlowState} from './VisProgTypes.tsx'; +import {type AppNode, type FlowState} from './VisProgTypes.tsx'; /** * contains the nodes that are created when the editor is loaded, @@ -55,61 +55,87 @@ const initialEdges = [ * and use any implemented functionality */ const useFlowStore = create((set, get) => ({ - nodes: initialNodes, - edges: initialEdges, - edgeReconnectSuccessful: true, - onNodesChange: (changes) => { + nodes: initialNodes, + edges: initialEdges, + edgeReconnectSuccessful: true, + onNodesChange: (changes) => { + set({ + nodes: applyNodeChanges(changes, get().nodes) + }); + }, + onEdgesChange: (changes) => { + set({ + edges: applyEdgeChanges(changes, get().edges) + }); + }, + // handles connection of newly created edges + onConnect: (connection) => { + set({ + edges: addEdge(connection, get().edges) + }); + }, + // handles attempted reconnections of a previously disconnected edge + onReconnect: (oldEdge: Edge, newConnection: Connection) => { + get().edgeReconnectSuccessful = true; + set({ + edges: reconnectEdge(oldEdge, newConnection, get().edges) + }); + }, + // Handles initiation of reconnection of edges that are manually disconnected from a node + onReconnectStart: () => { + set({ + edgeReconnectSuccessful: false + }); + }, + // Drops the edge from the set of edges, removing it from the flow, if no successful reconnection occurred + onReconnectEnd: (_: unknown, edge: { id: string; }) => { + if (!get().edgeReconnectSuccessful) { set({ - nodes: applyNodeChanges(changes, get().nodes) + edges: get().edges.filter((e) => e.id !== edge.id), }); - }, - onEdgesChange: (changes) => { - set({ - edges: applyEdgeChanges(changes, get().edges) - }); - }, - // handles connection of newly created edges - onConnect: (connection) => { - set({ - edges: addEdge(connection, get().edges) - }); - }, - // handles attempted reconnections of a previously disconnected edge - onReconnect: (oldEdge: Edge, newConnection: Connection) => { - get().edgeReconnectSuccessful = true; - set({ - edges: reconnectEdge(oldEdge, newConnection, get().edges) - }); - }, - // Handles initiation of reconnection of edges that are manually disconnected from a node - onReconnectStart: () => { - set({ - edgeReconnectSuccessful: false - }); - }, - // Drops the edge from the set of edges, removing it from the flow, if no successful reconnection occurred - onReconnectEnd: (_: unknown, edge: { id: string; }) => { - if (!get().edgeReconnectSuccessful) { - set({ - edges: get().edges.filter((e) => e.id !== edge.id), - }); - } - set({ - edgeReconnectSuccessful: true - }); - }, - deleteNode: (nodeId: string) => { - set({ - nodes: get().nodes.filter((n) => n.id !== nodeId), - edges: get().edges.filter((e) => e.source !== nodeId && e.target !== nodeId) - }); - }, - setNodes: (nodes) => { - set({nodes}); - }, - setEdges: (edges) => { - set({edges}); - }, + } + set({ + edgeReconnectSuccessful: true + }); + }, + deleteNode: (nodeId: string) => { + set({ + nodes: get().nodes.filter((n) => n.id !== nodeId), + edges: get().edges.filter((e) => e.source !== nodeId && e.target !== nodeId) + }); + }, + setNodes: (nodes) => { + set({nodes}); + }, + setEdges: (edges) => { + set({edges}); + }, +/** + * handles updating the data component of a node, + * if the provided data object contains entries that aren't present in the updated node's data component + * those entries are added to the data component, + * entries that do exist within the node's data component, + * are simply updated to contain the new value + * + * the data object + * @param {string} nodeId + * @param {object} data + */ + updateNodeData: (nodeId: string, data) => { + set({ + nodes: get().nodes.map((node) : AppNode => { + if (node.id === nodeId) { + return { + ...node, + data: { + ...node.data, + ...data + } + }; + } else { return node; } + }) + }); + } }), ); diff --git a/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx b/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx index 378f9be..bb7c28c 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx @@ -43,4 +43,5 @@ export type FlowState = { deleteNode: (nodeId: string) => void; setNodes: (nodes: AppNode[]) => void; setEdges: (edges: Edge[]) => void; + updateNodeData: (nodeId: string, data: object) => void; }; \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/NodeDefinitions.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/NodeDefinitions.tsx index f74dd2b..19f56dd 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/NodeDefinitions.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/NodeDefinitions.tsx @@ -1,4 +1,9 @@ -import {Handle, type NodeProps, NodeToolbar, Position} from '@xyflow/react'; +import { + Handle, + type NodeProps, + NodeToolbar, + Position +} from '@xyflow/react'; import '@xyflow/react/dist/style.css'; import styles from '../../VisProg.module.css'; import useFlowStore from "../VisProgStores.tsx"; @@ -9,7 +14,7 @@ import type { NormNode } from "../VisProgTypes.tsx"; -// +//Toolbar definitions type ToolbarProps = { nodeId: string; @@ -39,6 +44,56 @@ export function Toolbar({nodeId, allowDelete}: ToolbarProps) { ); } +// Renaming component + +/** + * Adds a component that can be used to edit a node's label entry inside its Data + * can be added to any custom node that has a label inside its Data + * + * @param {string} nodeLabel + * @param {string} nodeId + * @returns {React.JSX.Element} + * @constructor + */ +export function EditableName({nodeLabel = "new node", nodeId} : { nodeLabel : string, nodeId: string}) { + const {updateNodeData} = useFlowStore(); + + const updateData = (event: React.FocusEvent) => { + const input = event.target.value; + updateNodeData(nodeId, {label: input}); + event.currentTarget.setAttribute("readOnly", "true"); + window.getSelection()?.empty(); + event.currentTarget.classList.replace("nodrag", "drag"); // enable dragging of the node with cursor on the input box + }; + + const updateOnEnter = (event: React.KeyboardEvent) => { if (event.key === "Enter") (event.target as HTMLInputElement).blur(); }; + + const enableEditing = (event: React.MouseEvent) => { + if(event.currentTarget.hasAttribute("readOnly")) { + event.currentTarget.removeAttribute("readOnly"); // enable editing + event.currentTarget.select(); // select the text input + window.getSelection()?.collapseToEnd(); // move the caret to the end of the current value + event.currentTarget.classList.replace("drag", "nodrag"); // disable dragging using input box + } + } + + return ( +
+ + +
+ ) +} + // Definitions of Nodes @@ -54,7 +109,7 @@ export const StartNodeComponent = ({id, data}: NodeProps) => { return ( <> -
+
data test {data.label}
@@ -75,7 +130,7 @@ export const EndNodeComponent = ({id, data}: NodeProps) => { return ( <> -
+
{data.label}
@@ -96,8 +151,8 @@ export const PhaseNodeComponent = ({id, data}: NodeProps) => { return ( <> -
-
phase {data.number} {data.label}
+
+ @@ -119,8 +174,8 @@ export const NormNodeComponent = ({id, data}: NodeProps) => { return ( <> -
-
Norm {data.label}
+
+
diff --git a/test/pages/visProgPage/visualProgrammingUI/GraphReducer.test.ts b/test/pages/visProgPage/visualProgrammingUI/GraphReducer.test.ts index a907a58..4473b82 100644 --- a/test/pages/visProgPage/visualProgrammingUI/GraphReducer.test.ts +++ b/test/pages/visProgPage/visualProgrammingUI/GraphReducer.test.ts @@ -955,7 +955,7 @@ describe('Graph Reducer Tests', () => { state: onlyStartEnd, expected: [], } - ])("`tests state: $state.name`", ({state, expected}) => { + ])(`tests state: $state.name`, ({state, expected}) => { useFlowStore.setState({nodes: state.nodes, edges: state.edges}); const output = graphReducer(); // uses default reducers expect(output).toEqual(expected); diff --git a/test/pages/visProgPage/visualProgrammingUI/VisProgStores.test.tsx b/test/pages/visProgPage/visualProgrammingUI/VisProgStores.test.tsx index 9b3ab80..63fec3d 100644 --- a/test/pages/visProgPage/visualProgrammingUI/VisProgStores.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/VisProgStores.test.tsx @@ -221,4 +221,132 @@ describe('FlowStore Functionality', () => { }); }); }); + describe('ReactFlow updateNodeData', () => { + test.each([ + { + state: { + name: 'updateName', + nodes: [{ + id: 'phase-1', + type: 'phase', + position: {x: 0, y: 300}, + data: {label: 'name', number: '2'} + }] + }, + input: { + id: 'phase-1', + changedData: {label: 'new name'} + }, + expected: { + node: { + id: 'phase-1', + type: 'phase', + position: {x: 0, y: 300}, + data: {label: 'new name', number: '2'} + } + } + }, + { + state: { + name: 'updateNumber', + nodes: [{ + id: 'phase-1', + type: 'phase', + position: {x: 0, y: 300}, + data: {label: 'name', number: '2'} + }] + }, + input: { + id: 'phase-1', + changedData: {number: '3'} + }, + expected: { + node: { + id: 'phase-1', + type: 'phase', + position: {x: 0, y: 300}, + data: {label: 'name', number: '3'} + } + } + }, + { + state: { + name: 'updateNameAndNumber', + nodes: [{ + id: 'phase-1', + type: 'phase', + position: {x: 0, y: 300}, + data: {label: 'name', number: '2'} + }] + }, + input: { + id: 'phase-1', + changedData: {label: 'new name', number: '3'} + }, + expected: { + node: { + id: 'phase-1', + type: 'phase', + position: {x: 0, y: 300}, + data: {label: 'new name', number: '3'} + } + } + }, + { + state: { + name: 'AddNewEntry', + nodes: [{ + id: 'phase-1', + type: 'phase', + position: {x: 0, y: 300}, + data: {label: 'name', number: '2'} + }] + }, + input: { + id: 'phase-1', + changedData: {newEntry: 20} + }, + expected: { + node: { + id: 'phase-1', + type: 'phase', + position: {x: 0, y: 300}, + data: {label: 'name', number: '2', newEntry: 20} + } + } + }, + { + state: { + name: 'AddNewEntryAndUpdateOneValue_UnorderedInput', + nodes: [{ + id: 'phase-1', + type: 'phase', + position: {x: 0, y: 300}, + data: {label: 'name', number: '2'} + }] + }, + input: { + id: 'phase-1', + changedData: {newEntry: 20, number: '3'} + }, + expected: { + node: { + id: 'phase-1', + type: 'phase', + position: {x: 0, y: 300}, + data: {label: 'name', number: '3', newEntry: 20} + } + } + } + ])(`tests state: $state.name`, ({state, input,expected}) => { + useFlowStore.setState({ nodes: state.nodes }) + const {updateNodeData} = useFlowStore.getState(); + act(() => { + updateNodeData(input.id, input.changedData); + }) + const updatedState = useFlowStore.getState(); + expect(updatedState.nodes).toHaveLength(1); + expect(updatedState.nodes[0]).toMatchObject(expected.node); + }) + }) }); -- 2.49.1 From 45e133e255cdc984abe8d204b10d7cc8287a8d1c Mon Sep 17 00:00:00 2001 From: Tuurminator69 Date: Tue, 11 Nov 2025 16:05:25 +0100 Subject: [PATCH 091/184] feat: added temporary dummy button menu ref: N25B-189 --- package-lock.json | 30 +++++++++----- src/pages/VisProgPage/VisProg.module.css | 8 ++++ .../components/DragDropSidebar.tsx | 40 ++++++++++++++----- 3 files changed, 58 insertions(+), 20 deletions(-) diff --git a/package-lock.json b/package-lock.json index a1ed79f..852ac32 100644 --- a/package-lock.json +++ b/package-lock.json @@ -96,6 +96,7 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -691,6 +692,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -714,6 +716,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -2405,7 +2408,6 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -2416,7 +2418,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -2430,7 +2431,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -2445,8 +2445,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@testing-library/jest-dom": { "version": "6.9.1", @@ -2533,8 +2532,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -2710,6 +2708,7 @@ "integrity": "sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -2720,6 +2719,7 @@ "integrity": "sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.0.0" } @@ -2801,6 +2801,7 @@ "integrity": "sha512-EHrrEsyhOhxYt8MTg4zTF+DJMuNBzWwgvvOYNj/zm1vnaD/IC5zCXFehZv94Piqa2cRFfXrTFxIvO95L7Qc/cw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.44.1", "@typescript-eslint/types": "8.44.1", @@ -3389,6 +3390,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3669,6 +3671,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001741", @@ -4049,6 +4052,7 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -4193,8 +4197,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/eastasianwidth": { "version": "0.2.0", @@ -4324,6 +4327,7 @@ "integrity": "sha512-hB4FIzXovouYzwzECDcUkJ4OcfOEkXTv2zRY6B9bkwjx/cprAq0uvm1nl7zvQ0/TsUk0zQiN4uPfJpB9m+rPMQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5275,6 +5279,7 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -5926,6 +5931,7 @@ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -6101,7 +6107,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -6756,6 +6761,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz", "integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -7393,6 +7399,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -7608,6 +7615,7 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7767,6 +7775,7 @@ "integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -7860,6 +7869,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, diff --git a/src/pages/VisProgPage/VisProg.module.css b/src/pages/VisProgPage/VisProg.module.css index f2f90c7..5983702 100644 --- a/src/pages/VisProgPage/VisProg.module.css +++ b/src/pages/VisProgPage/VisProg.module.css @@ -40,6 +40,14 @@ align-items: center; } +.save-load-panel { + outline: 2.5pt solid black; + border-radius: 0 0 5pt 5pt; + border-color: dimgrey; + background-color: canvas; + align-items: center; +} + .dnd-node-container { background-color: canvas; justify-content: center; diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx index 383f72c..af359a6 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx @@ -131,18 +131,38 @@ export function DndToolbar() { ); return ( -
-
- You can drag these nodes to the pane to create new nodes. + + +
+
+
+ You can save and load your graph here. +
+
+ + Save Graph + + + Load Graph + +
-
- - phase Node - - - norm Node - + +
+
+ You can drag these nodes to the pane to create new nodes. +
+
+ + phase Node + + + norm Node + +
+ +
); } \ No newline at end of file -- 2.49.1 From 3cbf983b4154bdacea6528a6f98f742eaafce2ec Mon Sep 17 00:00:00 2001 From: Pim Hutting Date: Tue, 11 Nov 2025 16:32:37 +0100 Subject: [PATCH 092/184] fix: save and load are now buttons Really small change so me and Arthur can work on this toegether at the same time feat: N25B-189 --- package-lock.json | 30 +++++++------------ .../components/DragDropSidebar.tsx | 8 ++--- 2 files changed, 14 insertions(+), 24 deletions(-) diff --git a/package-lock.json b/package-lock.json index 852ac32..a1ed79f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -96,7 +96,6 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -692,7 +691,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -716,7 +714,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -2408,6 +2405,7 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -2418,6 +2416,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -2431,6 +2430,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -2445,7 +2445,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@testing-library/jest-dom": { "version": "6.9.1", @@ -2532,7 +2533,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -2708,7 +2710,6 @@ "integrity": "sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -2719,7 +2720,6 @@ "integrity": "sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.0.0" } @@ -2801,7 +2801,6 @@ "integrity": "sha512-EHrrEsyhOhxYt8MTg4zTF+DJMuNBzWwgvvOYNj/zm1vnaD/IC5zCXFehZv94Piqa2cRFfXrTFxIvO95L7Qc/cw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.44.1", "@typescript-eslint/types": "8.44.1", @@ -3390,7 +3389,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3671,7 +3669,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001741", @@ -4052,7 +4049,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -4197,7 +4193,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/eastasianwidth": { "version": "0.2.0", @@ -4327,7 +4324,6 @@ "integrity": "sha512-hB4FIzXovouYzwzECDcUkJ4OcfOEkXTv2zRY6B9bkwjx/cprAq0uvm1nl7zvQ0/TsUk0zQiN4uPfJpB9m+rPMQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5279,7 +5275,6 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -5931,7 +5926,6 @@ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -6107,6 +6101,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -6761,7 +6756,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz", "integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -7399,7 +7393,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -7615,7 +7608,6 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7775,7 +7767,6 @@ "integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -7869,7 +7860,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx index af359a6..e56501c 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx @@ -139,12 +139,12 @@ export function DndToolbar() { You can save and load your graph here.
- + +
-- 2.49.1 From 22da2ca6649d6a586b1d5d2c131e18ed946e5847 Mon Sep 17 00:00:00 2001 From: Pim Hutting Date: Wed, 12 Nov 2025 11:17:15 +0100 Subject: [PATCH 093/184] feat: added functionality of saving and loadiing for supported browsers, using the File System Access API. otherwise, fallback to download the file and then you can load from download ref: N25B-189 --- src/pages/VisProgPage/VisProg.tsx | 4 + .../components/DragDropSidebar.tsx | 15 -- .../components/SaveLoadPanel.tsx | 136 ++++++++++++++++++ 3 files changed, 140 insertions(+), 15 deletions(-) create mode 100644 src/pages/VisProgPage/visualProgrammingUI/components/SaveLoadPanel.tsx diff --git a/src/pages/VisProgPage/VisProg.tsx b/src/pages/VisProgPage/VisProg.tsx index 8208a70..8be3696 100644 --- a/src/pages/VisProgPage/VisProg.tsx +++ b/src/pages/VisProgPage/VisProg.tsx @@ -20,6 +20,7 @@ import graphReducer from "./visualProgrammingUI/GraphReducer.ts"; import useFlowStore from './visualProgrammingUI/VisProgStores.tsx'; import type {FlowState} from './visualProgrammingUI/VisProgTypes.tsx'; import styles from './VisProg.module.css' +import SaveLoadPanel from './visualProgrammingUI/components/SaveLoadPanel.tsx'; // --| config starting params for flow |-- @@ -100,6 +101,9 @@ const VisProgUI = () => { {/* contains the drag and drop panel for nodes */} + + + diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx index 6169b7b..91ba510 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx @@ -150,22 +150,7 @@ export function DndToolbar() { ); return ( - -
-
-
- You can save and load your graph here. -
-
- - -
-
diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/SaveLoadPanel.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/SaveLoadPanel.tsx new file mode 100644 index 0000000..ff0c041 --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/components/SaveLoadPanel.tsx @@ -0,0 +1,136 @@ +import React from "react"; +import useFlowStore from "../VisProgStores"; +import styles from "../../VisProg.module.css"; +import { useReactFlow, type Edge } from "@xyflow/react"; +import type { AppNode } from "../VisProgTypes"; + +type SavedProject = { + version: 1; + name: string; + savedAt: string; // ISO timestamp + nodes: AppNode[]; + edges: Edge[]; +}; + + + +function makeProjectBlob(name: string, nodes: AppNode[], edges: Edge[]): Blob { + const payload = { + version: 1, + name, + savedAt: new Date().toISOString(), + nodes, + edges, + }; + return new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" }); +} + +async function saveWithPicker(defaultName: string, blob: Blob) { + // @ts-expect-error: not in lib.dom.d.ts everywhere + if (window.showSaveFilePicker) { + // @ts-expect-error + const handle = await window.showSaveFilePicker({ + suggestedName: `${defaultName}.visprog.json`, + types: [{ description: "Visual Program Project", accept: { "application/json": [".visprog.json", ".json"] } }], + }); + const writable = await handle.createWritable(); + await writable.write(blob); + await writable.close(); + return; + } + // Fallback if File system API is not supported + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${defaultName}.visprog.json`; + a.click(); + URL.revokeObjectURL(url); +} + +async function loadWithPicker(): Promise { + try { + // @ts-expect-error + if (window.showOpenFilePicker) { + // @ts-expect-error + const [handle] = await window.showOpenFilePicker({ + multiple: false, + types: [{ description: "Visual Program Project", accept: { "application/json": [".visprog.json", ".json", ".txt"] } }], + }); + const file = await handle.getFile(); + return JSON.parse(await file.text()) as SavedProject; + } + // Fallback: input + return await new Promise((resolve) => { + const input = document.createElement("input"); + input.type = "file"; + input.accept = ".visprog.json,.json,.txt,application/json,text/plain"; + input.onchange = async () => { + const file = input.files?.[0]; + if (!file) return resolve(null); + try { + resolve(JSON.parse(await file.text()) as SavedProject); + } catch { + resolve(null); + } + }; + input.click(); + }); + } catch { + return null; + } +} + +export default function SaveLoadPanel() { + const nodes = useFlowStore((s) => s.nodes) as AppNode[]; + const edges = useFlowStore((s) => s.edges) as Edge[]; + const setNodes = useFlowStore((s) => s.setNodes); + const setEdges = useFlowStore((s) => s.setEdges); + + const onSave = async () => { + try { + const nameGuess = + (nodes.find((n) => n.type === "start")?.data?.label as string) || "visual-program"; + const blob = makeProjectBlob(nameGuess, nodes, edges); + await saveWithPicker(nameGuess, blob); + } catch (e) { + console.error(e); + alert("Saving failed. See console."); + } + }; + + const onLoad = async () => { + try { + const proj = await loadWithPicker(); + if (!proj) return; + + if (proj.version !== 1 || !Array.isArray(proj.nodes) || !Array.isArray(proj.edges)) { + alert("Invalid project file format."); + return; + } + + //We clear all the current edges and nodes + setEdges([]); + setNodes([]); + + //set all loaded nodes and edges into the VisProg + const loadedNodes = proj.nodes as AppNode[]; + const loadedEdges = proj.edges as Edge[]; + setNodes(loadedNodes); + setEdges(loadedEdges); + + } catch (e) { + console.error(e); + alert("Loading failed. See console."); + } + }; + + return ( +
+
You can save and load your graph here.
+
+ + +
+
+ ); +} -- 2.49.1 From 221fbe42c2a6f60bc3c7d2d952097c24948faf43 Mon Sep 17 00:00:00 2001 From: Pim Hutting Date: Wed, 12 Nov 2025 14:29:59 +0100 Subject: [PATCH 094/184] chore: added tests got 50.72% code coverage. Not sure if it is feasible to mock import behaviour ref: N25B-189 --- package.json | 3 +- .../components/SaveLoadPanel.tsx | 10 +- .../components/SaveLoadPanel.test.tsx | 172 ++++++++++++++++++ 3 files changed, 178 insertions(+), 7 deletions(-) create mode 100644 test/pages/visProgPage/visualProgrammingUI/components/SaveLoadPanel.test.tsx diff --git a/package.json b/package.json index cb88357..983d37b 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "test": "jest" }, "dependencies": { "@neodrag/react": "^2.3.1", diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/SaveLoadPanel.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/SaveLoadPanel.tsx index ff0c041..b4adc47 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/SaveLoadPanel.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/SaveLoadPanel.tsx @@ -1,8 +1,8 @@ -import React from "react"; import useFlowStore from "../VisProgStores"; import styles from "../../VisProg.module.css"; -import { useReactFlow, type Edge } from "@xyflow/react"; +import {type Edge } from "@xyflow/react"; import type { AppNode } from "../VisProgTypes"; +import { cleanup } from "@testing-library/react"; type SavedProject = { version: 1; @@ -14,7 +14,7 @@ type SavedProject = { -function makeProjectBlob(name: string, nodes: AppNode[], edges: Edge[]): Blob { +export function makeProjectBlob(name: string, nodes: AppNode[], edges: Edge[]): Blob { const payload = { version: 1, name, @@ -109,9 +109,7 @@ export default function SaveLoadPanel() { } //We clear all the current edges and nodes - setEdges([]); - setNodes([]); - + cleanup(); //set all loaded nodes and edges into the VisProg const loadedNodes = proj.nodes as AppNode[]; const loadedEdges = proj.edges as Edge[]; diff --git a/test/pages/visProgPage/visualProgrammingUI/components/SaveLoadPanel.test.tsx b/test/pages/visProgPage/visualProgrammingUI/components/SaveLoadPanel.test.tsx new file mode 100644 index 0000000..36fed6c --- /dev/null +++ b/test/pages/visProgPage/visualProgrammingUI/components/SaveLoadPanel.test.tsx @@ -0,0 +1,172 @@ +import { mockReactFlow } from '../../../../setupFlowTests.ts'; +import { act, render, screen, fireEvent } from '@testing-library/react'; +import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx'; +import { addNode } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx'; +import { makeProjectBlob } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/components/SaveLoadPanel.tsx'; +import SaveLoadPanel from '../../../../../src/pages/VisProgPage/visualProgrammingUI/components/SaveLoadPanel.tsx'; + +beforeAll(() => { + mockReactFlow(); +}); + +beforeEach(() => { + const { setNodes, setEdges } = useFlowStore.getState(); + act(() => { + setNodes([]); + setEdges([]); + }); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +describe('Load and save panel', () => { + test('save and load functions work correctly', async () => { + // create two nodes via your sidebar API + act(() => { + addNode('phase', { x: 100, y: 100 }); + addNode('norm', { x: 200, y: 200 }); + }); + + const initialState = useFlowStore.getState(); + expect(initialState.nodes.length).toBe(2); + + // make blob from current nodes/edges + const blob = makeProjectBlob('test-project', initialState.nodes, initialState.edges); + + // simulate loading from that blob + const parsed = JSON.parse(await blobToText(blob)); + + act(() => { + const { setNodes, setEdges } = useFlowStore.getState(); + setEdges([]); // clear edges first (mirrors app behavior) + setNodes(parsed.nodes); + setEdges(parsed.edges); + }); + + const loadedState = useFlowStore.getState(); + expect(loadedState.nodes.length).toBe(2); + expect(loadedState.nodes).toEqual(initialState.nodes); + expect(loadedState.edges).toEqual(initialState.edges); + }); + + test('Save uses showSaveFilePicker and writes JSON', async () => { + // Seed a simple graph so Save has something to write + act(() => { + useFlowStore.getState().setNodes([ + { id: 'start', type: 'start', position: { x: 0, y: 0 }, data: { label: 'start' } } as any, + { id: 'phase-1', type: 'phase', position: { x: 100, y: 120 }, data: { label: 'P1', number: 1 } } as any, + { id: 'end', type: 'end', position: { x: 0, y: 300 }, data: { label: 'End' } } as any, + ]); + useFlowStore.getState().setEdges([ + { id: 'start-phase-1', source: 'start', target: 'phase-1' } as any, + ]); + }); + + // capture what the app writes; don't decode inside the spy + let writtenChunk: any = null; + const write = jest.fn(async (chunk: any) => { writtenChunk = chunk; }); + const close = jest.fn().mockResolvedValue(undefined); + const createWritable = jest.fn().mockResolvedValue({ write, close }); + + // Mock the picker + (window as any).showSaveFilePicker = jest.fn().mockResolvedValue({ createWritable }); + + render(); + + await act(async () => { + fireEvent.click(screen.getByText(/Save Graph/i)); + }); + // @ts-expect-error + expect(window.showSaveFilePicker).toHaveBeenCalledTimes(1); + expect(createWritable).toHaveBeenCalledTimes(1); + expect(write).toHaveBeenCalledTimes(1); + expect(close).toHaveBeenCalledTimes(1); + + const writtenText = await chunkToString(writtenChunk); + const json = JSON.parse(writtenText); + expect(json.version).toBe(1); + expect(json.name).toBeDefined(); + expect(Array.isArray(json.nodes)).toBe(true); + expect(Array.isArray(json.edges)).toBe(true); + expect(json.behaviorProgram).toBeUndefined(); + }); + + test('Save falls back to anchor download when picker unavailable', async () => { + // Remove picker so we hit the fallback + delete (window as any).showSaveFilePicker; + + // Keep a reference to the REAL createElement to avoid recursion + const realCreateElement = document.createElement.bind(document); + + // Spy on URL + anchor click + const origCreateObjectURL = URL.createObjectURL; + const origRevokeObjectURL = URL.revokeObjectURL; + (URL as any).createObjectURL = jest.fn(() => 'blob:fake-url'); + (URL as any).revokeObjectURL = jest.fn(); + + const clickSpy = jest.fn(); + const createElementSpy = jest + .spyOn(document, 'createElement') + .mockImplementation((tag: any, opts?: any) => { + if (tag === 'a') { + // return a minimal anchor with a click spy + return { + set href(_v: string) {}, + set download(_v: string) {}, + click: clickSpy, + } as unknown as HTMLAnchorElement; + } + // call the REAL createElement for everything else + return realCreateElement(tag, opts as any); + }); + + render(); + + await act(async () => { + fireEvent.click(screen.getByText(/Save Graph/i)); + }); + + expect(URL.createObjectURL).toHaveBeenCalledTimes(1); + expect(clickSpy).toHaveBeenCalledTimes(1); + + // cleanup + createElementSpy.mockRestore(); + (URL as any).createObjectURL = origCreateObjectURL; + (URL as any).revokeObjectURL = origRevokeObjectURL; + }); +}); + +// +// helpers +// + +// portable blob reader (no Response needed) +async function blobToText(blob: Blob): Promise { + const anyBlob = blob as any; + if (typeof anyBlob.text === 'function') return anyBlob.text(); + if (typeof anyBlob.arrayBuffer === 'function') { + const buf = await anyBlob.arrayBuffer(); + return new TextDecoder().decode(buf); + } + return await new Promise((resolve, reject) => { + const fr = new FileReader(); + fr.onload = () => resolve(String(fr.result)); + fr.onerror = () => reject(fr.error); + fr.readAsText(blob); + }); +} + +// normalize whatever chunk createWritable.write receives to a string +async function chunkToString(chunk: any): Promise { + if (typeof chunk === 'string') return chunk; + if (chunk instanceof Blob) return blobToText(chunk); + if (chunk?.buffer instanceof ArrayBuffer) { + return new TextDecoder().decode(chunk as Uint8Array); + } + if (chunk instanceof ArrayBuffer) { + return new TextDecoder().decode(new Uint8Array(chunk)); + } + return String(chunk); +} -- 2.49.1 From 231d7a5ba151ddf3c43abd3bfd7a8646236d9b8e Mon Sep 17 00:00:00 2001 From: Twirre Date: Wed, 12 Nov 2025 14:35:38 +0000 Subject: [PATCH 095/184] Add logging with filters --- eslint.config.js | 33 ++- src/App.css | 86 +++++- src/App.tsx | 30 +- src/components/Logging/Filters.module.css | 34 +++ src/components/Logging/Filters.tsx | 200 +++++++++++++ src/components/Logging/Logging.module.css | 39 +++ src/components/Logging/Logging.tsx | 129 +++++++++ src/components/Logging/useLogs.ts | 146 ++++++++++ src/components/ScrollIntoView.tsx | 14 + src/index.css | 17 +- src/pages/VisProgPage/VisProg.module.css | 16 +- src/pages/VisProgPage/VisProg.tsx | 46 ++- src/utils/cellStore.ts | 29 ++ src/utils/formatDuration.ts | 21 ++ src/utils/priorityFiltering.ts | 24 ++ test/components/Logging/Filters.test.tsx | 328 ++++++++++++++++++++++ test/components/Logging/Logging.test.tsx | 239 ++++++++++++++++ test/components/Logging/useLogs.test.tsx | 246 ++++++++++++++++ test/eslint.config.js.ts | 0 test/utils/cellStore.test.tsx | 156 ++++++++++ test/utils/formatDuration.test.ts | 53 ++++ test/utils/priorityFiltering.test.ts | 81 ++++++ 22 files changed, 1899 insertions(+), 68 deletions(-) create mode 100644 src/components/Logging/Filters.module.css create mode 100644 src/components/Logging/Filters.tsx create mode 100644 src/components/Logging/Logging.module.css create mode 100644 src/components/Logging/Logging.tsx create mode 100644 src/components/Logging/useLogs.ts create mode 100644 src/components/ScrollIntoView.tsx create mode 100644 src/utils/cellStore.ts create mode 100644 src/utils/formatDuration.ts create mode 100644 src/utils/priorityFiltering.ts create mode 100644 test/components/Logging/Filters.test.tsx create mode 100644 test/components/Logging/Logging.test.tsx create mode 100644 test/components/Logging/useLogs.test.tsx create mode 100644 test/eslint.config.js.ts create mode 100644 test/utils/cellStore.test.tsx create mode 100644 test/utils/formatDuration.test.ts create mode 100644 test/utils/priorityFiltering.test.ts diff --git a/eslint.config.js b/eslint.config.js index b19330b..cd2d447 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,23 +1,38 @@ -import js from '@eslint/js' -import globals from 'globals' -import reactHooks from 'eslint-plugin-react-hooks' -import reactRefresh from 'eslint-plugin-react-refresh' -import tseslint from 'typescript-eslint' -import { defineConfig, globalIgnores } from 'eslint/config' +import js from "@eslint/js" +import globals from "globals" +import reactHooks from "eslint-plugin-react-hooks" +import reactRefresh from "eslint-plugin-react-refresh" +import tseslint from "typescript-eslint" +import { defineConfig, globalIgnores } from "eslint/config" export default defineConfig([ - globalIgnores(['dist']), + globalIgnores(["dist"]), { - files: ['**/*.{ts,tsx}'], + files: ["**/*.{ts,tsx}"], extends: [ js.configs.recommended, tseslint.configs.recommended, - reactHooks.configs['recommended-latest'], + reactHooks.configs["recommended-latest"], reactRefresh.configs.vite, ], languageOptions: { ecmaVersion: 2020, globals: globals.browser, }, + rules: { + "@typescript-eslint/no-unused-vars": [ + "warn", + { + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + }, + ], + }, + }, + { + files: ["test/**/*.{ts,tsx}"], + rules: { + "@typescript-eslint/no-explicit-any": "off", + }, }, ]) diff --git a/src/App.css b/src/App.css index ab28aa0..8ce14c8 100644 --- a/src/App.css +++ b/src/App.css @@ -82,6 +82,10 @@ button.movePage:hover{ } +#root { + display: flex; + flex-direction: column; +} header { position: sticky; @@ -96,6 +100,7 @@ header { align-items: center; justify-content: center; + background-color: var(--accent-color); backdrop-filter: blur(10px); z-index: 1; /* Otherwise any translated elements render above the blur?? */ } @@ -121,6 +126,14 @@ main { flex-wrap: wrap; } +.min-height-0 { + min-height: 0; +} + +.scroll-y { + overflow-y: scroll; +} + .align-center { align-items: center; } @@ -141,6 +154,10 @@ main { gap: 1rem; } +.margin-0 { + margin: 0; +} + .padding-sm { padding: .25rem; } @@ -150,7 +167,19 @@ main { .padding-lg { padding: 1rem; } +.padding-b-sm { + padding-bottom: .25rem; +} +.padding-b-md { + padding-bottom: .5rem; +} +.padding-b-lg { + padding-bottom: 1rem; +} +.round-sm, .round-md, .round-lg { + overflow: hidden; +} .round-sm { border-radius: .25rem; } @@ -159,4 +188,59 @@ main { } .round-lg { border-radius: 1rem; -} \ No newline at end of file +} + +.border-sm { + border: 1px solid canvastext; +} +.border-md { + border: 2px solid canvastext; +} +.border-lg { + border: 3px solid canvastext; +} + +.font-small { + font-size: .75rem; +} +.font-medium { + font-size: 1rem; +} +.font-large { + font-size: 1.25rem; +} +.mono { + font-family: ui-monospace, monospace; +} +.bold { + font-weight: bold; +} + + +.clickable { + cursor: pointer; + + &:hover { + text-decoration: underline; + } +} +.user-select-all { + -webkit-user-select: all; + user-select: all; +} +.user-select-none { + -webkit-user-select: none; + user-select: none; +} +button.no-button { + background: none; + border: none; + padding: 0; + cursor: pointer; + color: inherit; + text-decoration: none; + + &:hover { + text-decoration: underline; + } +} diff --git a/src/App.tsx b/src/App.tsx index 968c979..70fc815 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,23 +4,31 @@ import TemplatePage from './pages/TemplatePage/Template.tsx' import Home from './pages/Home/Home.tsx' import Robot from './pages/Robot/Robot.tsx'; 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); + return ( -
+ <>
Home +
-
- - } /> - } /> - } /> - } /> - -
-
- ) +
+
+ + } /> + } /> + } /> + } /> + +
+ {showLogs && } +
+ + ); } export default App diff --git a/src/components/Logging/Filters.module.css b/src/components/Logging/Filters.module.css new file mode 100644 index 0000000..405560c --- /dev/null +++ b/src/components/Logging/Filters.module.css @@ -0,0 +1,34 @@ +.filter-root { + position: relative; + display: flex; + align-items: center; + justify-content: center; +} + +.filter-panel { + position: absolute; + display: flex; + flex-direction: column; + gap: .25rem; + top: 0; + right: 0; + z-index: 1; + background: canvas; + box-shadow: 0 0 1rem rgba(0, 0, 0, 0.5); + width: 300px; + + *:first-child { + margin-top: 0; + } + *:last-child { + margin-bottom: 0; + } +} + +button.deletable { + cursor: pointer; + + &:hover { + text-decoration: line-through; + } +} diff --git a/src/components/Logging/Filters.tsx b/src/components/Logging/Filters.tsx new file mode 100644 index 0000000..446a9c6 --- /dev/null +++ b/src/components/Logging/Filters.tsx @@ -0,0 +1,200 @@ +import {useEffect, useRef, useState} from "react"; + +import type {LogFilterPredicate} from "./useLogs.ts"; + +import styles from "./Filters.module.css"; + +type Setter = (value: T | ((prev: T) => T)) => void; + +const optionMapping = new Map([ + ["ALL", 0], + ["DEBUG", 10], + ["INFO", 20], + ["WARNING", 30], + ["ERROR", 40], + ["CRITICAL", 50], + ["NONE", 999_999_999_999], // It is technically possible to have a higher level, but this is fine +]); + +function LevelPredicateElement({ + name, + level, + setLevel, + onDelete, +}: { + name: string; + level: string; + setLevel: (level: string) => void; + onDelete?: () => void; +}) { + const normalizedName = name.split(".").pop() || name; + + return
+ + +
+} + +const GLOBAL_LOG_LEVEL_PREDICATE_KEY = "global_log_level"; + +function GlobalLevelFilter({ + filterPredicates, + setFilterPredicates, +}: { + filterPredicates: Map; + setFilterPredicates: Setter>; +}) { + const selected = filterPredicates.get(GLOBAL_LOG_LEVEL_PREDICATE_KEY)?.value || "ALL"; + const setSelected = (selected: string | null) => { + if (!selected || !optionMapping.has(selected)) return; + + setFilterPredicates((curr) => { + const next = new Map(curr); + next.set(GLOBAL_LOG_LEVEL_PREDICATE_KEY, { + predicate: (record) => record.levelno >= optionMapping.get(selected)!, + priority: 0, + value: selected, + }); + return next; + }); + } + + useEffect(() => { + if (filterPredicates.has(GLOBAL_LOG_LEVEL_PREDICATE_KEY)) return; + setSelected("INFO"); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // Run only once when the component mounts, not when anything changes + + return ; +} + +const AGENT_LOG_LEVEL_PREDICATE_KEY_PREFIX = "agent_log_level_"; + +function AgentLevelFilters({ + filterPredicates, + setFilterPredicates, + agentNames, +}: { + filterPredicates: Map; + setFilterPredicates: Setter>; + agentNames: Set; +}) { + const rootRef = useRef(null); + const [open, setOpen] = useState(false); + + // Click outside to close + useEffect(() => { + if (!open) return; + const onDocClick = (e: MouseEvent) => { + if (!rootRef.current?.contains(e.target as Node)) setOpen(false); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key !== "Escape") return; + setOpen(false); + e.preventDefault(); // Don't exit fullscreen mode + }; + document.addEventListener("mousedown", onDocClick); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("mousedown", onDocClick); + document.removeEventListener("keydown", onKey); + }; + }, [open]); + + const agentPredicates = [...filterPredicates.keys()].filter((key) => + key.startsWith(AGENT_LOG_LEVEL_PREDICATE_KEY_PREFIX)); + + /** + * Create or change the predicate for an agent. If the level is not given, the global level is used. + * @param agentName The name of the agent. + * @param level The level to filter by. If not given, the global level is used. + */ + const setAgentPredicate = (agentName: string, level?: string ) => { + level = level ?? filterPredicates.get(GLOBAL_LOG_LEVEL_PREDICATE_KEY)?.value ?? "ALL"; + setFilterPredicates((prev) => { + const next = new Map(prev); + next.set(AGENT_LOG_LEVEL_PREDICATE_KEY_PREFIX + agentName, { + predicate: (record) => record.name === agentName + ? record.levelno >= optionMapping.get(level!)! + : null, + priority: 1, + value: {agentName, level}, + }); + return next; + }); + } + + const deleteAgentPredicate = (agentName: string) => { + setFilterPredicates((curr) => { + const fullName = AGENT_LOG_LEVEL_PREDICATE_KEY_PREFIX + agentName; + if (!curr.has(fullName)) return curr; // Return unchanged, no re-render + const next = new Map(curr); + next.delete(fullName); + return next; + }); + } + + return <> + {agentPredicates.map((key) => { + const {agentName, level} = filterPredicates.get(key)!.value; + + return setAgentPredicate(agentName, level)} + onDelete={() => deleteAgentPredicate(agentName)} + />; + })} +
+ + +
+ ; +} + +export default function Filters({ + filterPredicates, + setFilterPredicates, + agentNames, +}: { + filterPredicates: Map; + setFilterPredicates: Setter>; + agentNames: Set; +}) { + return
+ + +
; +} diff --git a/src/components/Logging/Logging.module.css b/src/components/Logging/Logging.module.css new file mode 100644 index 0000000..6fc2988 --- /dev/null +++ b/src/components/Logging/Logging.module.css @@ -0,0 +1,39 @@ +.logging-container { + box-sizing: border-box; + + width: max(30dvw, 500px); + flex-shrink: 0; + + box-shadow: 0 0 1rem black; + padding: 1rem 1rem 0 1rem; +} + +.no-numbers { + list-style-type: none; + counter-reset: none; + padding-inline-start: 0; +} + +.log-container { + margin-bottom: .5rem; + + .accented-0, .accented-10 { + background-color: color-mix(in oklab, canvas, rgb(159, 159, 159) 35%) + } + .accented-20 { + background-color: color-mix(in oklab, canvas, green 35%) + } + .accented-30 { + background-color: color-mix(in oklab, canvas, yellow 35%) + } + .accented-40, .accented-50 { + background-color: color-mix(in oklab, canvas, red 35%) + } +} + +.floating-button { + position: fixed; + bottom: 1rem; + right: 1rem; + box-shadow: 0 0 1rem rgba(0, 0, 0, 0.5); +} \ No newline at end of file diff --git a/src/components/Logging/Logging.tsx b/src/components/Logging/Logging.tsx new file mode 100644 index 0000000..ede0bcc --- /dev/null +++ b/src/components/Logging/Logging.tsx @@ -0,0 +1,129 @@ +import {useEffect, useRef, useState} from "react"; +import {create} from "zustand"; + +import formatDuration from "../../utils/formatDuration.ts"; +import {type LogFilterPredicate, type LogRecord, useLogs} from "./useLogs.ts"; +import Filters from "./Filters.tsx"; +import {type Cell, useCell} from "../../utils/cellStore.ts"; + +import styles from "./Logging.module.css"; + +type LoggingSettings = { + showRelativeTime: boolean; + setShowRelativeTime: (showRelativeTime: boolean) => void; + scrollToBottom: boolean; + setScrollToBottom: (scrollToBottom: boolean) => void; +}; + +const useLoggingSettings = create((set) => ({ + showRelativeTime: false, + setShowRelativeTime: (showRelativeTime: boolean) => set({ showRelativeTime }), + scrollToBottom: true, + setScrollToBottom: (scrollToBottom: boolean) => set({ scrollToBottom }), +})); + +function LogMessage({ + recordCell, + onUpdate, +}: { + recordCell: Cell, + onUpdate?: () => void, +}) { + const { showRelativeTime, setShowRelativeTime } = useLoggingSettings(); + const record = useCell(recordCell); + + /** + * Normalizes the log level number to a multiple of 10, for which there are CSS styles. + */ + const normalizedLevelNo = (() => { + // By default, the highest level is 50 (CRITICAL). Custom levels can be higher, but we don't have more critical color. + if (record.levelno >= 50) return 50; + + return Math.round(record.levelno / 10) * 10; + })(); + + const normalizedName = record.name.split(".").pop() || record.name; + + useEffect(() => { + if (onUpdate) onUpdate(); + }, [record, onUpdate]); + + return
+
+ {record.levelname} + setShowRelativeTime(!showRelativeTime)} + >{showRelativeTime + ? formatDuration(record.relativeCreated) + : new Date(record.created * 1000).toLocaleTimeString() + } +
+
+ {normalizedName} + {record.message} +
+
; +} + +function LogMessages({ recordCells }: { recordCells: Cell[] }) { + const scrollableRef = useRef(null); + const lastElementRef = useRef(null) + const { scrollToBottom, setScrollToBottom } = useLoggingSettings(); + + useEffect(() => { + if (!scrollableRef.current) return; + const currentScrollableRef = scrollableRef.current; + + const handleScroll = () => setScrollToBottom(false); + + currentScrollableRef.addEventListener("wheel", handleScroll); + currentScrollableRef.addEventListener("touchmove", handleScroll); + + return () => { + currentScrollableRef.removeEventListener("wheel", handleScroll); + currentScrollableRef.removeEventListener("touchmove", handleScroll); + } + }, [scrollableRef, setScrollToBottom]); + + function scrollLastElementIntoView(force = false) { + if ((!scrollToBottom && !force) || !lastElementRef.current) return; + lastElementRef.current.scrollIntoView({ behavior: "smooth" }); + } + + return
+
    + {recordCells.map((recordCell, i) => ( +
  1. + +
  2. + ))} +
  3. +
+ {!scrollToBottom && } +
; +} + +export default function Logging() { + const [filterPredicates, setFilterPredicates] = useState(new Map()); + const { filteredLogs, distinctNames } = useLogs(filterPredicates) + + return
+
+

Logs

+ +
+ +
; +} diff --git a/src/components/Logging/useLogs.ts b/src/components/Logging/useLogs.ts new file mode 100644 index 0000000..76eed92 --- /dev/null +++ b/src/components/Logging/useLogs.ts @@ -0,0 +1,146 @@ +import {useCallback, useEffect, useRef, useState} from "react"; + +import {applyPriorityPredicates, type PriorityFilterPredicate} from "../../utils/priorityFiltering.ts"; +import {cell, type Cell} from "../../utils/cellStore.ts"; + +export type LogRecord = { + name: string; + message: string; + levelname: 'DEBUG' | 'INFO' | 'WARNING' | 'ERROR' | 'CRITICAL' | string; + levelno: number; + created: number; + relativeCreated: number; + reference?: string; + firstCreated: number; + firstRelativeCreated: number; +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type LogFilterPredicate = PriorityFilterPredicate & { value: any }; + +export function useLogs(filterPredicates: Map) { + const [distinctNames, setDistinctNames] = useState>(new Set()); + const [filtered, setFiltered] = useState[]>([]); + + const sseRef = useRef(null); + const filtersRef = useRef(filterPredicates); + const logsRef = useRef([]); + + /** Map to store the first message for each reference, instance can be updated to change contents. */ + const firstByRefRef = useRef>>(new Map()); + + /** + * Apply the filter predicates to a log record. + * @param log The log record to apply the filters to. + * @returns `true` if the record passes. + */ + const applyFilters = useCallback((log: LogRecord) => + applyPriorityPredicates(log, [...filtersRef.current.values()]), []); + + /** Recomputes the entire filtered list. Use when filter predicates change. */ + const recomputeFiltered = useCallback(() => { + const newFiltered: Cell[] = []; + firstByRefRef.current = new Map(); + + for (const message of logsRef.current) { + const messageCell = cell({ + ...message, + firstCreated: message.created, + firstRelativeCreated: message.relativeCreated, + }); + + if (message.reference) { + const first = firstByRefRef.current.get(message.reference); + if (first) { + // Update the first's contents + first.set((prev) => ({ + ...message, + firstCreated: prev.firstCreated ?? prev.created, + firstRelativeCreated: prev.firstRelativeCreated ?? prev.relativeCreated, + })); + + // Don't add it to the list again + continue; + } else { + // Add the first message with this reference to the registry + firstByRefRef.current.set(message.reference, messageCell); + } + } + + if (applyFilters(message)) { + newFiltered.push(messageCell); + } + } + + setFiltered(newFiltered); + }, [applyFilters, setFiltered]); + + // Reapply filters to all logs, only when filters change + useEffect(() => { + filtersRef.current = filterPredicates; + recomputeFiltered(); + }, [filterPredicates, recomputeFiltered]); + + /** + * Handle a new log message. Updates the filtered list and to the full history. + * @param message The new log message. + */ + const handleNewMessage = useCallback((message: LogRecord) => { + // Add to the full history for re-filtering on filter changes + logsRef.current.push(message); + + setDistinctNames((prev) => { + if (prev.has(message.name)) return prev; + const newSet = new Set(prev); + newSet.add(message.name); + return newSet; + }); + + const messageCell = cell({ + ...message, + firstCreated: message.created, + firstRelativeCreated: message.relativeCreated, + }); + + if (message.reference) { + const first = firstByRefRef.current.get(message.reference); + if (first) { + // Update the first's contents + first.set((prev) => ({ + ...message, + firstCreated: prev.firstCreated ?? prev.created, + firstRelativeCreated: prev.firstRelativeCreated ?? prev.relativeCreated, + })); + + // Don't add it to the list again + return; + } else { + // Add the first message with this reference to the registry + firstByRefRef.current.set(message.reference, messageCell); + } + } + + if (applyFilters(message)) { + setFiltered((curr) => [...curr, messageCell]); + } + }, [applyFilters, setFiltered]); + + useEffect(() => { + if (sseRef.current) return; + + const es = new EventSource("http://localhost:8000/logs/stream"); + sseRef.current = es; + + es.onmessage = (event) => { + const data: LogRecord = JSON.parse(event.data); + handleNewMessage(data); + }; + + return () => { + es.close(); + sseRef.current = null; + }; + }, [handleNewMessage]); + + return {filteredLogs: filtered, distinctNames}; +} diff --git a/src/components/ScrollIntoView.tsx b/src/components/ScrollIntoView.tsx new file mode 100644 index 0000000..bcbc7d4 --- /dev/null +++ b/src/components/ScrollIntoView.tsx @@ -0,0 +1,14 @@ +import {useEffect, useRef} from "react"; + +/** + * An element that always scrolls into view when it is rendered. When added to a list, the entire list will scroll to show this element. + */ +export default function ScrollIntoView() { + const elementRef = useRef(null); + + useEffect(() => { + if (elementRef.current) elementRef.current.scrollIntoView({ behavior: "smooth" }); + }); + + return
; +} diff --git a/src/index.css b/src/index.css index 4d39cfb..986e666 100644 --- a/src/index.css +++ b/src/index.css @@ -7,13 +7,15 @@ color: rgba(255, 255, 255, 0.87); background-color: #242424; + --accent-color: #008080; + font-synthesis: none; text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } -html, body { +html, body, #root { margin: 0; padding: 0; @@ -25,11 +27,7 @@ html, body { a { font-weight: 500; - color: #646cff; - text-decoration: inherit; -} -a:hover { - color: #535bf2; + color: canvastext; } h1 { @@ -49,7 +47,7 @@ button { transition: border-color 0.25s; } button:hover { - border-color: #646cff; + border-color: var(--accent-color); } button:focus, button:focus-visible { @@ -60,9 +58,8 @@ button:focus-visible { :root { color: #213547; background-color: #ffffff; - } - a:hover { - color: #747bff; + + --accent-color: #00AAAA; } button { background-color: #f9f9f9; diff --git a/src/pages/VisProgPage/VisProg.module.css b/src/pages/VisProgPage/VisProg.module.css index c58d0f3..34a6ecc 100644 --- a/src/pages/VisProgPage/VisProg.module.css +++ b/src/pages/VisProgPage/VisProg.module.css @@ -1,19 +1,9 @@ /* editor UI */ -.outer-editor-container { - margin-inline: auto; - display: flex; - justify-self: center; - padding: 10px; - align-items: center; - width: 80vw; - height: 80vh; -} - .inner-editor-container { - outline-style: solid; - border-radius: 10pt; - width: 90%; + box-sizing: border-box; + margin: 1rem; + width: calc(100% - 2rem); height: 100%; } diff --git a/src/pages/VisProgPage/VisProg.tsx b/src/pages/VisProgPage/VisProg.tsx index 8208a70..829bbfc 100644 --- a/src/pages/VisProgPage/VisProg.tsx +++ b/src/pages/VisProgPage/VisProg.tsx @@ -80,30 +80,28 @@ const VisProgUI = () => { } = useFlowStore(useShallow(selector)); // instructs the editor to use the corresponding functions from the FlowStore return ( -
-
- - - {/* contains the drag and drop panel for nodes */} - - - - -
+
+ + + {/* contains the drag and drop panel for nodes */} + + + +
); }; diff --git a/src/utils/cellStore.ts b/src/utils/cellStore.ts new file mode 100644 index 0000000..eb64907 --- /dev/null +++ b/src/utils/cellStore.ts @@ -0,0 +1,29 @@ +import {useSyncExternalStore} from "react"; + +type Unsub = () => void; + +export type Cell = { + get: () => T; + set: (next: T | ((prev: T) => T)) => void; + subscribe: (callback: () => void) => Unsub; +}; + +export function cell(initial: T): Cell { + let value = initial; + const listeners = new Set<() => void>(); + return { + get: () => value, + set: (next) => { + value = typeof next === "function" ? (next as (v: T) => T)(value) : next; + for (const l of listeners) l(); + }, + subscribe: (callback) => { + listeners.add(callback); + return () => listeners.delete(callback); + }, + }; +} + +export function useCell(c: Cell) { + return useSyncExternalStore(c.subscribe, c.get, c.get); +} diff --git a/src/utils/formatDuration.ts b/src/utils/formatDuration.ts new file mode 100644 index 0000000..2e9f88d --- /dev/null +++ b/src/utils/formatDuration.ts @@ -0,0 +1,21 @@ +/** + * Format a time duration like `HH:MM:SS.mmm`. + * + * @param durationMs time duration in milliseconds. + * @return formatted time string. + */ +export default function formatDuration(durationMs: number): string { + const isNegative = durationMs < 0; + if (isNegative) durationMs = -durationMs; + + const hours = Math.floor(durationMs / 3600000); + const minutes = Math.floor((durationMs % 3600000) / 60000); + const seconds = Math.floor((durationMs % 60000) / 1000); + const milliseconds = Math.floor(durationMs % 1000); + + return (isNegative ? '-' : '') + + `${hours.toString().padStart(2, '0')}:` + + `${minutes.toString().padStart(2, '0')}:` + + `${seconds.toString().padStart(2, '0')}.` + + `${milliseconds.toString().padStart(3, '0')}`; +} diff --git a/src/utils/priorityFiltering.ts b/src/utils/priorityFiltering.ts new file mode 100644 index 0000000..7638f34 --- /dev/null +++ b/src/utils/priorityFiltering.ts @@ -0,0 +1,24 @@ +export type PriorityFilterPredicate = { + priority: number; + predicate: (element: T) => boolean | null; // The predicate and its priority are ignored if it returns null. +} + +/** + * Applies a list of priority predicates to an element. For all predicates that don't return null, if the ones with the highest level return true, then this function returns true. + * @param element The element to apply the predicates to. + * @param predicates The list of predicates to apply. + */ +export function applyPriorityPredicates(element: T, predicates: PriorityFilterPredicate[]): boolean { + let highestPriority = -1; + let highestKeep = true; + for (const predicate of predicates) { + if (predicate.priority >= highestPriority) { + const predicateKeep = predicate.predicate(element); + if (predicateKeep === null) continue; // This predicate doesn't care about the element, so skip it + if (predicate.priority > highestPriority) highestKeep = true; + highestPriority = predicate.priority; + highestKeep = highestKeep && predicateKeep; + } + } + return highestKeep; +} diff --git a/test/components/Logging/Filters.test.tsx b/test/components/Logging/Filters.test.tsx new file mode 100644 index 0000000..9d5e40b --- /dev/null +++ b/test/components/Logging/Filters.test.tsx @@ -0,0 +1,328 @@ +import {render, screen, waitFor, fireEvent} from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import * as React from "react"; + +type ControlledUseState = typeof React.useState & { + __forceNextReturn?: (value: any) => jest.Mock; + __resetMockState?: () => void; +}; + +jest.mock("react", () => { + const actual = jest.requireActual("react"); + const queue: Array<{value: any; setter: jest.Mock}> = []; + const mockUseState = ((initial: any) => { + if (queue.length) { + const {value, setter} = queue.shift()!; + return [value, setter]; + } + return actual.useState(initial); + }) as ControlledUseState; + + mockUseState.__forceNextReturn = (value: any) => { + const setter = jest.fn(); + queue.push({value, setter}); + return setter; + }; + mockUseState.__resetMockState = () => { + queue.length = 0; + }; + + return { + __esModule: true, + ...actual, + useState: mockUseState, + }; +}); +import Filters from "../../../src/components/Logging/Filters.tsx"; +import type {LogFilterPredicate, LogRecord} from "../../../src/components/Logging/useLogs.ts"; + +const GLOBAL = "global_log_level"; +const AGENT_PREFIX = "agent_log_level_"; +const optionMapping = new Map([ + ["ALL", 0], + ["DEBUG", 10], + ["INFO", 20], + ["WARNING", 30], + ["ERROR", 40], + ["CRITICAL", 50], + ["NONE", 999_999_999_999], +]); + +const controlledUseState = React.useState as ControlledUseState; + +afterEach(() => { + controlledUseState.__resetMockState?.(); +}); + +function getCallArg(mock: jest.Mock, index = 0): T { + return mock.mock.calls[index][0] as T; +} + +function sampleRecord(levelno: number, name = "any.logger"): LogRecord { + return { + levelname: "UNKNOWN", + levelno, + name, + message: "Whatever", + created: 0, + relativeCreated: 0, + firstCreated: 0, + firstRelativeCreated: 0, + }; +} + +// -------------------------------------------------------------------------- + +describe("Filters", () => { + describe("Global level filter", () => { + it("initializes to INFO when missing", async () => { + const setFilterPredicates = jest.fn(); + const filterPredicates = new Map(); + + const view = render( + ()} + /> + ); + + // Effect sets default to INFO + await waitFor(() => { + expect(setFilterPredicates).toHaveBeenCalled(); + }); + + const updater = getCallArg<(prev: Map) => Map>(setFilterPredicates); + const newMap = updater(filterPredicates); + const global = newMap.get(GLOBAL)!; + + expect(global.value).toBe("INFO"); + expect(global.priority).toBe(0); + // Predicate gate at INFO (>= 20) + expect(global.predicate(sampleRecord(10))).toBe(false); + expect(global.predicate(sampleRecord(20))).toBe(true); + + // UI shows INFO selected after parent state updates + view.rerender( + ()} + /> + ); + + const globalSelect = screen.getByLabelText("Global:"); + expect((globalSelect as HTMLSelectElement).value).toBe("INFO"); + }); + + it("updates predicate when selecting a higher level", async () => { + // Start with INFO already present + const existing = new Map([ + [ + GLOBAL, + { + value: "INFO", + priority: 0, + predicate: (r: any) => r.levelno >= optionMapping.get("INFO")! + } + ] + ]); + + const setFilterPredicates = jest.fn(); + const user = userEvent.setup(); + + render( + ()} + /> + ); + + const select = screen.getByLabelText("Global:"); + await user.selectOptions(select, "ERROR"); + + const updater = getCallArg<(prev: Map) => Map>(setFilterPredicates); + const updated = updater(existing); + const global = updated.get(GLOBAL)!; + + expect(global.value).toBe("ERROR"); + expect(global.priority).toBe(0); + expect(global.predicate(sampleRecord(30))).toBe(false); + expect(global.predicate(sampleRecord(40))).toBe(true); + }); + }); + + describe("Agent level filters", () => { + it("adds an agent using the current global level when none specified", async () => { + // Global set to WARNING + const existing = new Map([ + [ + GLOBAL, + { + value: "WARNING", + priority: 0, + predicate: (r: any) => r.levelno >= optionMapping.get("WARNING")! + } + ] + ]); + + const setFilterPredicates = jest.fn(); + const user = userEvent.setup(); + + render( + (["pepper.speech", "vision.agent"])} + /> + ); + + const addSelect = screen.getByLabelText("Add:"); + await user.selectOptions(addSelect, "pepper.speech"); + + // Agent setter is functional: prev => next + const updater = getCallArg<(prev: Map) => Map>(setFilterPredicates); + const next = updater(existing); + + const key = AGENT_PREFIX + "pepper.speech"; + const agentPred = next.get(key)!; + + expect(agentPred.priority).toBe(1); + expect(agentPred.value).toEqual({agentName: "pepper.speech", level: "WARNING"}); + // When agentName matches, enforce WARNING (>= 30) + expect(agentPred.predicate(sampleRecord(20, "pepper.speech"))).toBe(false); + expect(agentPred.predicate(sampleRecord(30, "pepper.speech"))).toBe(true); + // Other agents -> null + expect(agentPred.predicate(sampleRecord(999, "other"))).toBeNull(); + }); + + it("changes an agent's level when its select is updated", async () => { + // Prepopulate agent predicate at WARNING + const key = AGENT_PREFIX + "pepper.speech"; + const existing = new Map([ + [ + GLOBAL, + { + value: "INFO", + priority: 0, + predicate: (r: any) => r.levelno >= optionMapping.get("INFO")! + } + ], + [ + key, + { + value: {agentName: "pepper.speech", level: "WARNING"}, + priority: 1, + predicate: (r: any) => (r.name === "pepper.speech" ? r.levelno >= optionMapping.get("WARNING")! : null) + } + ] + ]); + + const setFilterPredicates = jest.fn(); + const user = userEvent.setup(); + + const element = render( + + ); + + const agentSelect = element.container.querySelector("select#log_level_pepper\\.speech")!; + + await user.selectOptions(agentSelect, "ERROR"); + + const updater = getCallArg<(prev: Map) => Map>(setFilterPredicates); + const next = updater(existing); + const updated = next.get(key)!; + + expect(updated.value).toEqual({agentName: "pepper.speech", level: "ERROR"}); + // Threshold moved to ERROR (>= 40) + expect(updated.predicate(sampleRecord(30, "pepper.speech"))).toBe(false); + expect(updated.predicate(sampleRecord(40, "pepper.speech"))).toBe(true); + }); + + it("deletes an agent predicate when clicking its name button", async () => { + const key = AGENT_PREFIX + "pepper.speech"; + const existing = new Map([ + [ + GLOBAL, + { + value: "INFO", + priority: 0, + predicate: (r: any) => r.levelno >= optionMapping.get("INFO")! + } + ], + [ + key, + { + value: {agentName: "pepper.speech", level: "INFO"}, + priority: 1, + predicate: (r: any) => (r.name === "pepper.speech" ? r.levelno >= optionMapping.get("INFO")! : null) + } + ] + ]); + + const setFilterPredicates = jest.fn(); + const user = userEvent.setup(); + + render( + (["pepper.speech"])} + /> + ); + + const deleteBtn = screen.getByRole("button", {name: "speech:"}); + await user.click(deleteBtn); + + const updater = getCallArg<(prev: Map) => Map>(setFilterPredicates); + const next = updater(existing); + expect(next.has(key)).toBe(false); + }); + }); + + describe("Filter popup behavior", () => { + function renderWithPopupOpen() { + const existing = new Map([ + [ + GLOBAL, + { + value: "INFO", + priority: 0, + predicate: (r: any) => r.levelno >= optionMapping.get("INFO")! + } + ] + ]); + const setFilterPredicates = jest.fn(); + const forceNext = controlledUseState.__forceNextReturn; + if (!forceNext) throw new Error("useState mock missing helper"); + const setOpen = forceNext(true); + + render( + + ); + + return { setOpen }; + } + + it("closes the popup when clicking outside", () => { + const { setOpen } = renderWithPopupOpen(); + fireEvent.mouseDown(document.body); + expect(setOpen).toHaveBeenCalledWith(false); + }); + + it("closes the popup when pressing Escape", () => { + const { setOpen } = renderWithPopupOpen(); + fireEvent.keyDown(document, { key: "Escape" }); + expect(setOpen).toHaveBeenCalledWith(false); + }); + }); +}); diff --git a/test/components/Logging/Logging.test.tsx b/test/components/Logging/Logging.test.tsx new file mode 100644 index 0000000..03d4a92 --- /dev/null +++ b/test/components/Logging/Logging.test.tsx @@ -0,0 +1,239 @@ +import {render, screen, fireEvent, act, waitFor} from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import "@testing-library/jest-dom"; +import type {Cell} from "../../../src/utils/cellStore.ts"; +import {cell} from "../../../src/utils/cellStore.ts"; +import type {LogFilterPredicate, LogRecord} from "../../../src/components/Logging/useLogs.ts"; + +const mockFiltersRender = jest.fn(); +const loggingStoreRef: { current: null | { setState: (state: Partial) => void } } = { current: null }; + +type LoggingSettingsState = { + showRelativeTime: boolean; + setShowRelativeTime: (show: boolean) => void; + scrollToBottom: boolean; + setScrollToBottom: (scroll: boolean) => void; +}; + +jest.mock("zustand", () => { + const actual = jest.requireActual("zustand"); + const actualCreate = actual.create; + return { + __esModule: true, + ...actual, + create: (...args: any[]) => { + const store = actualCreate(...args); + const state = store.getState(); + if ("setShowRelativeTime" in state && "setScrollToBottom" in state) { + loggingStoreRef.current = store; + } + return store; + }, + }; +}); + +jest.mock("../../../src/components/Logging/Filters.tsx", () => { + const React = jest.requireActual("react"); + return { + __esModule: true, + default: (props: any) => { + mockFiltersRender(props); + return React.createElement("div", {"data-testid": "filters-mock"}, "filters"); + }, + }; +}); + +jest.mock("../../../src/components/Logging/useLogs.ts", () => { + const actual = jest.requireActual("../../../src/components/Logging/useLogs.ts"); + return { + __esModule: true, + ...actual, + useLogs: jest.fn(), + }; +}); + +import {useLogs} from "../../../src/components/Logging/useLogs.ts"; +const mockUseLogs = useLogs as jest.MockedFunction; + +type LoggingComponent = typeof import("../../../src/components/Logging/Logging.tsx").default; +let Logging: LoggingComponent; + +beforeAll(async () => { + if (!Element.prototype.scrollIntoView) { + Object.defineProperty(Element.prototype, "scrollIntoView", { + configurable: true, + writable: true, + value: function () {}, + }); + } + + ({default: Logging} = await import("../../../src/components/Logging/Logging.tsx")); +}); + +beforeEach(() => { + mockUseLogs.mockReset(); + mockFiltersRender.mockReset(); + mockUseLogs.mockReturnValue({filteredLogs: [], distinctNames: new Set()}); + resetLoggingStore(); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +function resetLoggingStore() { + loggingStoreRef.current?.setState({ + showRelativeTime: false, + scrollToBottom: true, + }); +} + +function makeRecord(overrides: Partial = {}): LogRecord { + return { + name: "pepper.logger", + message: "default", + levelname: "INFO", + levelno: 20, + created: 1, + relativeCreated: 1, + firstCreated: 1, + firstRelativeCreated: 1, + ...overrides, + }; +} + +function makeCell(overrides: Partial = {}): Cell { + return cell(makeRecord(overrides)); +} + +describe("Logging component", () => { + it("renders log messages and toggles the timestamp between absolute and relative view", async () => { + const logCell = makeCell({ + name: "pepper.trace.logging", + message: "Ping", + levelname: "WARNING", + levelno: 30, + created: 1_700_000_000, + relativeCreated: 12_345, + firstCreated: 1_700_000_000, + firstRelativeCreated: 12_345, + }); + + const names = new Set(["pepper.trace.logging"]); + mockUseLogs.mockReturnValue({filteredLogs: [logCell], distinctNames: names}); + + jest.spyOn(Date.prototype, "toLocaleTimeString").mockReturnValue("ABS TIME"); + const user = userEvent.setup(); + + render(); + + expect(screen.getByText("Logs")).toBeInTheDocument(); + expect(screen.getByText("WARNING")).toBeInTheDocument(); + expect(screen.getByText("logging")).toBeInTheDocument(); + expect(screen.getByText("Ping")).toBeInTheDocument(); + + let timestamp = screen.queryByText("ABS TIME"); + if (!timestamp) { + // if previous test left the store toggled, click once to show absolute time + timestamp = screen.getByText("00:00:12.345"); + await user.click(timestamp); + timestamp = screen.getByText("ABS TIME"); + } + + await user.click(timestamp); + expect(screen.getByText("00:00:12.345")).toBeInTheDocument(); + }); + + it("shows the scroll-to-bottom button after a manual scroll and scrolls when clicked", async () => { + const logs = [ + makeCell({message: "first", firstRelativeCreated: 1}), + makeCell({message: "second", firstRelativeCreated: 2}), + ]; + mockUseLogs.mockReturnValue({filteredLogs: logs, distinctNames: new Set()}); + + const scrollSpy = jest.spyOn(Element.prototype, "scrollIntoView").mockImplementation(() => {}); + const user = userEvent.setup(); + const view = render(); + + expect(screen.queryByRole("button", {name: "Scroll to bottom"})).toBeNull(); + + const scrollable = view.container.querySelector(".scroll-y"); + expect(scrollable).toBeTruthy(); + + fireEvent.wheel(scrollable!); + + const button = await screen.findByRole("button", {name: "Scroll to bottom"}); + await user.click(button); + + expect(scrollSpy).toHaveBeenCalled(); + await waitFor(() => { + expect(screen.queryByRole("button", {name: "Scroll to bottom"})).toBeNull(); + }); + }); + + it("scrolls the last element into view when a log cell updates", async () => { + const logCell = makeCell({message: "Initial", firstRelativeCreated: 42}); + mockUseLogs.mockReturnValue({filteredLogs: [logCell], distinctNames: new Set()}); + + const scrollSpy = jest.spyOn(Element.prototype, "scrollIntoView").mockImplementation(() => {}); + render(); + + await waitFor(() => { + expect(scrollSpy).toHaveBeenCalledTimes(1); + }); + scrollSpy.mockClear(); + + act(() => { + const current = logCell.get(); + logCell.set({...current, message: "Updated"}); + }); + + expect(screen.getByText("Updated")).toBeInTheDocument(); + await waitFor(() => { + expect(scrollSpy).toHaveBeenCalledTimes(1); + }); + }); + + it("passes filter state to Filters and re-invokes useLogs when predicates change", async () => { + const distinct = new Set(["pepper.core"]); + mockUseLogs.mockImplementation((_filters: Map) => ({ + filteredLogs: [], + distinctNames: distinct, + })); + + render(); + + expect(mockFiltersRender).toHaveBeenCalledTimes(1); + const firstProps = mockFiltersRender.mock.calls[0][0]; + expect(firstProps.agentNames).toBe(distinct); + + const initialMap = firstProps.filterPredicates; + expect(initialMap).toBeInstanceOf(Map); + expect(initialMap.size).toBe(0); + expect(mockUseLogs).toHaveBeenCalledWith(initialMap); + + const updatedPredicate: LogFilterPredicate = { + value: "custom", + priority: 0, + predicate: () => true, + }; + + act(() => { + firstProps.setFilterPredicates((prev: Map) => { + const next = new Map(prev); + next.set("custom", updatedPredicate); + return next; + }); + }); + + await waitFor(() => { + expect(mockUseLogs).toHaveBeenCalledTimes(2); + }); + + const nextFilters = mockUseLogs.mock.calls[1][0]; + expect(nextFilters.get("custom")).toBe(updatedPredicate); + + const secondProps = mockFiltersRender.mock.calls[mockFiltersRender.mock.calls.length - 1][0]; + expect(secondProps.filterPredicates).toBe(nextFilters); + }); +}); diff --git a/test/components/Logging/useLogs.test.tsx b/test/components/Logging/useLogs.test.tsx new file mode 100644 index 0000000..30a7c2d --- /dev/null +++ b/test/components/Logging/useLogs.test.tsx @@ -0,0 +1,246 @@ +import { render, screen, act } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import {type LogRecord, useLogs} from "../../../src/components/Logging/useLogs.ts"; +import {type cell, useCell} from "../../../src/utils/cellStore.ts"; +import { StrictMode } from "react"; + +jest.mock("../../../src/utils/priorityFiltering.ts", () => ({ + applyPriorityPredicates: jest.fn((_log, preds: any[]) => + preds.every(() => true) // default: pass all + ), +})); +import {applyPriorityPredicates} from "../../../src/utils/priorityFiltering.ts"; + +class MockEventSource { + url: string; + onmessage: ((event: { data: string }) => void) | null = null; + onerror: ((event: unknown) => void) | null = null; + close = jest.fn(); + + constructor(url: string) { + this.url = url; + // expose the latest instance for tests: + (globalThis as any).__es = this; + } +} + +beforeAll(() => { + globalThis.EventSource = MockEventSource as any; +}); + +afterEach(() => { + // reset mock so previous instance not reused accidentally + (globalThis as any).__es = undefined; + jest.clearAllMocks(); +}); + +function LogsProbe({ filters }: { filters: Map }) { + const { filteredLogs, distinctNames } = useLogs(filters); + + return ( +
+
{distinctNames.size}
+
    + {filteredLogs.map((c, i) => ( + + ))} +
+
+ ); +} + +function LogItem({ cell: c, index }: { cell: ReturnType>; index: number }) { + const value = useCell(c); + return ( +
  • + {value.name} + {value.message} + {String(value.firstCreated)} + {String(value.created)} + {value.reference ?? ""} +
  • + ); +} + +function emit(log: LogRecord) { + const eventSource = (globalThis as any).__es as MockEventSource; + if (!eventSource || !eventSource.onmessage) throw new Error("EventSource not initialized"); + act(() => { + eventSource.onmessage!({ data: JSON.stringify(log) }); + }); +} + +describe("useLogs (unit)", () => { + it("creates EventSource once and closes on unmount", () => { + const filters = new Map(); // allow all by default + const { unmount } = render( + + + + ); + const es = (globalThis as any).__es as MockEventSource; + expect(es).toBeTruthy(); + expect(es.url).toBe("http://localhost:8000/logs/stream"); + + unmount(); + expect(es.close).toHaveBeenCalledTimes(1); + }); + + it("appends filtered logs and collects distinct names", () => { + const filters = new Map(); + render( + + + + ); + + expect(screen.getByTestId("names-count")).toHaveTextContent("0"); + + emit({ + levelname: "DEBUG", + levelno: 10, + name: "alpha", + message: "m1", + created: 1, + relativeCreated: 1, + firstCreated: 1, + firstRelativeCreated: 1, + }); + emit({ + levelname: "DEBUG", + levelno: 10, + name: "beta", + message: "m2", + created: 2, + relativeCreated: 2, + firstCreated: 2, + firstRelativeCreated: 2, + }); + emit({ + levelname: "DEBUG", + levelno: 10, + name: "alpha", + message: "m3", + created: 3, + relativeCreated: 3, + firstCreated: 3, + firstRelativeCreated: 3, + }); + + // 3 messages (no reference), 2 distinct names + expect(screen.getAllByRole("listitem")).toHaveLength(3); + expect(screen.getByTestId("names-count")).toHaveTextContent("2"); + + expect(screen.getByTestId("log-0-name")).toHaveTextContent("alpha"); + expect(screen.getByTestId("log-1-name")).toHaveTextContent("beta"); + expect(screen.getByTestId("log-2-name")).toHaveTextContent("alpha"); + }); + + it("updates first message with reference when a second one with that reference comes", () => { + const filters = new Map(); + render(); + + // First message with ref r1 + emit({ + levelname: "DEBUG", + levelno: 10, + name: "svc", + message: "first", + reference: "r1", + created: 10, + relativeCreated: 10, + firstCreated: 10, + firstRelativeCreated: 10, + }); + + // Second message with same ref r1, should still be a single item + emit({ + levelname: "DEBUG", + levelno: 10, + name: "svc", + message: "second", + reference: "r1", + created: 20, + relativeCreated: 20, + firstCreated: 20, + firstRelativeCreated: 20, + }); + + const items = screen.getAllByRole("listitem"); + expect(items).toHaveLength(1); + + // Same single item, but message should be "second" + expect(screen.getByTestId("log-0-msg")).toHaveTextContent("second"); + // The "firstCreated" should remain the original (10), while "created" is now 20 + expect(screen.getByTestId("log-0-first")).toHaveTextContent("10"); + expect(screen.getByTestId("log-0-created")).toHaveTextContent("20"); + expect(screen.getByTestId("log-0-ref")).toHaveTextContent("r1"); + }); + + it("runs recomputeFiltered when filters change", () => { + const allowAll = new Map(); + const { rerender } = render(); + + emit({ + levelname: "DEBUG", + levelno: 10, + name: "n1", + message: "ok", + created: 1, + relativeCreated: 1, + firstCreated: 1, + firstRelativeCreated: 1, + }); + emit({ + levelname: "DEBUG", + levelno: 10, + name: "n2", + message: "ok", + created: 2, + relativeCreated: 2, + firstCreated: 2, + firstRelativeCreated: 2, + }); + emit({ + levelname: "INFO", + levelno: 20, + name: "n3", + message: "ok1", + reference: "r1", + created: 3, + relativeCreated: 3, + firstCreated: 3, + firstRelativeCreated: 3, + }); + emit({ + levelname: "INFO", + levelno: 20, + name: "n3", + message: "ok2", + reference: "r1", + created: 4, + relativeCreated: 4, + firstCreated: 4, + firstRelativeCreated: 4, + }); + + expect(screen.getAllByRole("listitem")).toHaveLength(3); + + // Now change filters to block all < INFO + (applyPriorityPredicates as jest.Mock).mockImplementation((l) => l.levelno >= 20); + const blockDebug = new Map([["dummy", { value: true }]]); + rerender(); + + // Should recompute with shorter list + expect(screen.queryAllByRole("listitem")).toHaveLength(1); + + // Switch back to allow-all + (applyPriorityPredicates as jest.Mock).mockImplementation((_log, preds: any[]) => + preds.every(() => true) + ); + rerender(); + + // recompute should restore all three + expect(screen.getAllByRole("listitem")).toHaveLength(3); + }); +}); diff --git a/test/eslint.config.js.ts b/test/eslint.config.js.ts new file mode 100644 index 0000000..e69de29 diff --git a/test/utils/cellStore.test.tsx b/test/utils/cellStore.test.tsx new file mode 100644 index 0000000..96460b8 --- /dev/null +++ b/test/utils/cellStore.test.tsx @@ -0,0 +1,156 @@ +import {render, screen, act} from "@testing-library/react"; +import "@testing-library/jest-dom"; +import {type Cell, cell, useCell} from "../../src/utils/cellStore.ts"; + +describe("cell store (unit)", () => { + it("returns initial value with get()", () => { + const c = cell(123); + expect(c.get()).toBe(123); + }); + + it("updates value with set(next)", () => { + const c = cell("a"); + c.set("b"); + expect(c.get()).toBe("b"); + }); + + it("gives previous value in set(updater)", () => { + const c = cell(1); + c.set((prev) => prev + 2); + expect(c.get()).toBe(3); + }); + + it("calls subscribe callback on set", () => { + const c = cell(0); + const cb = jest.fn(); + const unsub = c.subscribe(cb); + + c.set(1); + c.set(2); + + expect(cb).toHaveBeenCalledTimes(2); + unsub(); + }); + + it("stops notifications when unsubscribing", () => { + const c = cell(0); + const cb = jest.fn(); + const unsub = c.subscribe(cb); + + c.set(1); + unsub(); + c.set(2); + + expect(cb).toHaveBeenCalledTimes(1); + }); + + it("updates multiple listeners", () => { + const c = cell("x"); + const a = jest.fn(); + const b = jest.fn(); + const ua = c.subscribe(a); + const ub = c.subscribe(b); + + c.set("y"); + expect(a).toHaveBeenCalledTimes(1); + expect(b).toHaveBeenCalledTimes(1); + + ua(); + ub(); + }); +}); + +describe("cell store (integration)", () => { + function View({c, label}: { c: Cell; label: string }) { + const v = useCell(c); + // count renders to verify re-render behavior + (View as any).__renders = ((View as any).__renders ?? 0) + 1; + return
    {String(v)}
    ; + } + + it("reads initial value and updates on set", () => { + const c = cell("hello"); + + render(); + + expect(screen.getByTestId("value")).toHaveTextContent("hello"); + + act(() => { + c.set("world"); + }); + + expect(screen.getByTestId("value")).toHaveTextContent("world"); + }); + + it("triggers one re-render with set", () => { + const c = cell(1); + (View as any).__renders = 0; + + render(); + + const rendersAfterMount = (View as any).__renders; + + act(() => { + c.set((prev: number) => prev + 1); + }); + + // exactly one extra render from the update + expect((View as any).__renders).toBe(rendersAfterMount + 1); + expect(screen.getByTestId("num")).toHaveTextContent("2"); + }); + + it("unsubscribes on unmount (no errors on later sets)", () => { + const c = cell("a"); + + const {unmount} = render(); + + unmount(); + + // should not throw even though there was a subscriber + expect(() => + act(() => { + c.set("b"); + }) + ).not.toThrow(); + }); + + it("only re-renders components that use the cell", () => { + const a = cell("A"); + const b = cell("B"); + + let rendersA = 0; + let rendersB = 0; + + function A() { + const v = useCell(a); + rendersA++; + return
    {v}
    ; + } + + function B() { + const v = useCell(b); + rendersB++; + return
    {v}
    ; + } + + render( + <> + + + + ); + + const rendersAAfterMount = rendersA; + const rendersBAfterMount = rendersB; + + act(() => { + a.set("A2"); // only A should update + }); + + expect(screen.getByTestId("A")).toHaveTextContent("A2"); + expect(screen.getByTestId("B")).toHaveTextContent("B"); + + expect(rendersA).toBe(rendersAAfterMount + 1); + expect(rendersB).toBe(rendersBAfterMount); // unchanged + }); +}); diff --git a/test/utils/formatDuration.test.ts b/test/utils/formatDuration.test.ts new file mode 100644 index 0000000..b686a43 --- /dev/null +++ b/test/utils/formatDuration.test.ts @@ -0,0 +1,53 @@ +import formatDuration from "../../src/utils/formatDuration.ts"; + +describe("formatting durations (unit)", () => { + it("does one millisecond", () => { + const result = formatDuration(1); + expect(result).toBe("00:00:00.001"); + }); + + it("does one-hundred twenty-three milliseconds", () => { + const result = formatDuration(123); + expect(result).toBe("00:00:00.123"); + }); + + it("does one second", () => { + const result = formatDuration(1*1000); + expect(result).toBe("00:00:01.000"); + }); + + it("does thirteen seconds", () => { + const result = formatDuration(13*1000); + expect(result).toBe("00:00:13.000"); + }); + + it("does one minute", () => { + const result = formatDuration(60*1000); + expect(result).toBe("00:01:00.000"); + }); + + it("does thirteen minutes", () => { + const result = formatDuration(13*60*1000); + expect(result).toBe("00:13:00.000"); + }); + + it("does one hour", () => { + const result = formatDuration(60*60*1000); + expect(result).toBe("01:00:00.000"); + }); + + it("does thirteen hours", () => { + const result = formatDuration(13*60*60*1000); + expect(result).toBe("13:00:00.000"); + }); + + it("does negative one millisecond", () => { + const result = formatDuration(-1); + expect(result).toBe("-00:00:00.001"); + }); + + it("does large negative durations", () => { + const result = formatDuration(-(123*60*60*1000 + 59*60*1000 + 59*1000 + 123)); + expect(result).toBe("-123:59:59.123"); + }); +}); diff --git a/test/utils/priorityFiltering.test.ts b/test/utils/priorityFiltering.test.ts new file mode 100644 index 0000000..6cc8789 --- /dev/null +++ b/test/utils/priorityFiltering.test.ts @@ -0,0 +1,81 @@ +import {applyPriorityPredicates, type PriorityFilterPredicate} from "../../src/utils/priorityFiltering"; + +const makePred = (priority: number, fn: (el: T) => boolean | null): PriorityFilterPredicate => ({ + priority, + predicate: jest.fn(fn), +}); + +describe("applyPriorityPredicates (unit)", () => { + beforeEach(() => jest.clearAllMocks()); + + it("returns true when there are no predicates", () => { + expect(applyPriorityPredicates(123, [])).toBe(true); + }); + + it("behaves like a normal predicate with only one predicate", () => { + const even = makePred(1, (n) => n % 2 === 0); + expect(applyPriorityPredicates(2, [even])).toBe(true); + expect(applyPriorityPredicates(3, [even])).toBe(false); + }); + + it("determines the result only listening to the highest priority predicates", () => { + const lowFail = makePred(1, (_) => false); + const lowPass = makePred(1, (_) => true); + const highPass = makePred(10, (n) => n > 0); + const highFail = makePred(10, (n) => n < 0); + + expect(applyPriorityPredicates(5, [lowFail, highPass])).toBe(true); + expect(applyPriorityPredicates(5, [lowPass, highFail])).toBe(false); + }); + + it("uses all predicates at the highest priority", () => { + const high1 = makePred(5, (n) => n % 2 === 0); + const high2 = makePred(5, (n) => n > 2); + expect(applyPriorityPredicates(4, [high1, high2])).toBe(true); + expect(applyPriorityPredicates(2, [high1, high2])).toBe(false); + }); + + it("is order independent (later higher positive clears earlier lower negative)", () => { + const lowFalse = makePred(1, (_) => false); + const highTrue = makePred(9, (n) => n === 7); + + // Higher priority appears later → should reset and decide by highest only + expect(applyPriorityPredicates(7, [lowFalse, highTrue])).toBe(true); + + // Same set, different order → same result + expect(applyPriorityPredicates(7, [highTrue, lowFalse])).toBe(true); + }); + + it("handles many priorities: only max matters", () => { + const p1 = makePred(1, (_) => false); + const p3 = makePred(3, (_) => false); + const p5 = makePred(5, (n) => n > 0); + expect(applyPriorityPredicates(1, [p1, p3, p5])).toBe(true); + }); + + it("skips predicates that return null", () => { + const high = makePred(10, (n) => n === 0 ? true : null); + const low = makePred(1, (_) => false); + expect(applyPriorityPredicates(0, [high, low])).toBe(true); + expect(applyPriorityPredicates(1, [high, low])).toBe(false); + }); +}); + +describe("(integration) filter with applyPriorityPredicates", () => { + it("filters an array using only highest-priority predicates", () => { + const elems = [1, 2, 3, 4, 5]; + const low = makePred(0, (_) => false); + const high1 = makePred(5, (n) => n % 2 === 0); + const high2 = makePred(5, (n) => n > 2); + const result = elems.filter((e) => applyPriorityPredicates(e, [low, high1, high2])); + expect(result).toEqual([4]); + }); + + it("filters an array using only highest-priority predicates", () => { + const elems = [1, 2, 3, 4, 5]; + const low = makePred(0, (_) => false); + const high = makePred(5, (n) => n === 3 ? true : null); + const result = elems.filter((e) => applyPriorityPredicates(e, [low, high])); + expect(result).toEqual([3]); + }); +}); -- 2.49.1 From aeaf526797f1ea96ce9921923bf3c87831281a48 Mon Sep 17 00:00:00 2001 From: Twirre Date: Thu, 13 Nov 2025 10:50:12 +0000 Subject: [PATCH 096/184] Make nodes editable: norms, goals and keyword triggers --- src/App.css | 4 + src/components/TextField.module.css | 27 ++++ src/components/TextField.tsx | 101 +++++++++++++ src/pages/VisProgPage/VisProg.module.css | 26 ++++ src/pages/VisProgPage/VisProg.tsx | 9 +- .../visualProgrammingUI/GraphReducer.ts | 37 +++-- .../visualProgrammingUI/GraphReducerTypes.ts | 20 ++- .../visualProgrammingUI/VisProgTypes.tsx | 7 +- .../components/DragDropSidebar.tsx | 48 +++++- .../components/NodeDefinitions.tsx | 143 ++++++++++-------- .../components/TriggerNodeComponent.tsx | 121 +++++++++++++++ src/utils/duplicateIndices.ts | 19 +++ .../visualProgrammingUI/GraphReducer.test.ts | 96 ++++++++++-- test/utils/duplicateIndices.test.ts | 22 +++ 14 files changed, 580 insertions(+), 100 deletions(-) create mode 100644 src/components/TextField.module.css create mode 100644 src/components/TextField.tsx create mode 100644 src/pages/VisProgPage/visualProgrammingUI/components/TriggerNodeComponent.tsx create mode 100644 src/utils/duplicateIndices.ts create mode 100644 test/utils/duplicateIndices.test.ts diff --git a/src/App.css b/src/App.css index 8ce14c8..a241d03 100644 --- a/src/App.css +++ b/src/App.css @@ -109,6 +109,10 @@ main { padding: 1rem 0; } +input[type="checkbox"] { + cursor: pointer; +} + .flex-row { display: flex; flex-direction: row; diff --git a/src/components/TextField.module.css b/src/components/TextField.module.css new file mode 100644 index 0000000..de66531 --- /dev/null +++ b/src/components/TextField.module.css @@ -0,0 +1,27 @@ +.text-field { + border: 1px solid transparent; + border-radius: 5pt; + padding: 4px 8px; + outline: none; + background-color: canvas; + transition: border-color 0.2s, box-shadow 0.2s; + cursor: text; +} + +.text-field.invalid { + border-color: red; + color: red; +} + +.text-field:focus:not(.invalid) { + border-color: color-mix(in srgb, canvas, #777 10%); +} + +.text-field:read-only { + cursor: pointer; + background-color: color-mix(in srgb, canvas, #777 5%); +} + +.text-field:read-only:hover:not(.invalid) { + border-color: color-mix(in srgb, canvas, #777 10%); +} diff --git a/src/components/TextField.tsx b/src/components/TextField.tsx new file mode 100644 index 0000000..58de55d --- /dev/null +++ b/src/components/TextField.tsx @@ -0,0 +1,101 @@ +import {useState} from "react"; +import styles from "./TextField.module.css"; + +/** + * A text input element in our own style that calls `setValue` at every keystroke. + * + * @param {Object} props - The component props. + * @param {string} props.value - The value of the text input. + * @param {(value: string) => void} props.setValue - A function that sets the value of the text input. + * @param {string} [props.placeholder] - The placeholder text for the text input. + * @param {string} [props.className] - Additional CSS classes for the text input. + * @param {string} [props.id] - The ID of the text input. + * @param {string} [props.ariaLabel] - The ARIA label for the text input. + */ +export function RealtimeTextField({ + value = "", + setValue, + onCommit, + placeholder, + className, + id, + ariaLabel, + invalid = false, +} : { + value: string, + setValue: (value: string) => void, + onCommit: () => void, + placeholder?: string, + className?: string, + id?: string, + ariaLabel?: string, + invalid?: boolean, +}) { + const [readOnly, setReadOnly] = useState(true); + + const updateData = () => { + setReadOnly(true); + onCommit(); + }; + + const updateOnEnter = (event: React.KeyboardEvent) => { if (event.key === "Enter") (event.target as HTMLInputElement).blur(); }; + + return setValue(e.target.value)} + onFocus={() => setReadOnly(false)} + onBlur={updateData} + onKeyDown={updateOnEnter} + readOnly={readOnly} + id={id} + // ReactFlow uses the "drag" / "nodrag" classes to enable / disable dragging of nodes + className={`${readOnly ? "drag" : "nodrag"} ${styles.textField} ${invalid ? styles.invalid : ""} ${className}`} + aria-label={ariaLabel} + />; +} + +/** + * A text input element in our own style that calls `setValue` once the user presses the enter key or clicks outside the input. + * + * @param {Object} props - The component props. + * @param {string} props.value - The value of the text input. + * @param {(value: string) => void} props.setValue - A function that sets the value of the text input. + * @param {string} [props.placeholder] - The placeholder text for the text input. + * @param {string} [props.className] - Additional CSS classes for the text input. + * @param {string} [props.id] - The ID of the text input. + * @param {string} [props.ariaLabel] - The ARIA label for the text input. + */ +export function TextField({ + value = "", + setValue, + placeholder, + className, + id, + ariaLabel, + invalid = false, +} : { + value: string, + setValue: (value: string) => void, + placeholder?: string, + className?: string, + id?: string, + ariaLabel?: string, + invalid?: boolean, +}) { + const [inputValue, setInputValue] = useState(value); + + const onCommit = () => setValue(inputValue); + + return ; +} diff --git a/src/pages/VisProgPage/VisProg.module.css b/src/pages/VisProgPage/VisProg.module.css index 34a6ecc..e19b34a 100644 --- a/src/pages/VisProgPage/VisProg.module.css +++ b/src/pages/VisProgPage/VisProg.module.css @@ -71,6 +71,16 @@ filter: drop-shadow(0 0 0.25rem forestgreen); } +.node-goal { + outline: yellow solid 2pt; + filter: drop-shadow(0 0 0.25rem yellow); +} + +.node-trigger { + outline: teal solid 2pt; + filter: drop-shadow(0 0 0.25rem teal); +} + .node-phase { outline: dodgerblue solid 2pt; filter: drop-shadow(0 0 0.25rem dodgerblue); @@ -102,6 +112,22 @@ filter: drop-shadow(0 0 0.25rem forestgreen); } +.draggable-node-goal { + padding: 3px 10px; + background-color: canvas; + border-radius: 5pt; + outline: yellow solid 2pt; + filter: drop-shadow(0 0 0.25rem yellow); +} + +.draggable-node-trigger { + padding: 3px 10px; + background-color: canvas; + border-radius: 5pt; + outline: teal solid 2pt; + filter: drop-shadow(0 0 0.25rem teal); +} + .draggable-node-phase { padding: 3px 10px; background-color: canvas; diff --git a/src/pages/VisProgPage/VisProg.tsx b/src/pages/VisProgPage/VisProg.tsx index 829bbfc..1dd1804 100644 --- a/src/pages/VisProgPage/VisProg.tsx +++ b/src/pages/VisProgPage/VisProg.tsx @@ -13,13 +13,15 @@ import { StartNodeComponent, EndNodeComponent, PhaseNodeComponent, - NormNodeComponent + NormNodeComponent, + GoalNodeComponent, } from './visualProgrammingUI/components/NodeDefinitions.tsx'; import {DndToolbar} from './visualProgrammingUI/components/DragDropSidebar.tsx'; import graphReducer from "./visualProgrammingUI/GraphReducer.ts"; import useFlowStore from './visualProgrammingUI/VisProgStores.tsx'; import type {FlowState} from './visualProgrammingUI/VisProgTypes.tsx'; import styles from './VisProg.module.css' +import TriggerNodeComponent from "./visualProgrammingUI/components/TriggerNodeComponent.tsx"; // --| config starting params for flow |-- @@ -30,7 +32,9 @@ const NODE_TYPES = { start: StartNodeComponent, end: EndNodeComponent, phase: PhaseNodeComponent, - norm: NormNodeComponent + norm: NormNodeComponent, + goal: GoalNodeComponent, + trigger: TriggerNodeComponent, }; /** @@ -126,6 +130,7 @@ function VisualProgrammingUI() { function runProgram() { const program = graphReducer(); console.log(program); + console.log(JSON.stringify(program, null, 2)); } /** diff --git a/src/pages/VisProgPage/visualProgrammingUI/GraphReducer.ts b/src/pages/VisProgPage/visualProgrammingUI/GraphReducer.ts index 138eb82..6a4ee55 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/GraphReducer.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/GraphReducer.ts @@ -15,14 +15,15 @@ import type { Phase, PhaseReducer, PreparedGraph, - PreparedPhase + PreparedPhase, Reduced, TriggerReducer } from "./GraphReducerTypes.ts"; import type { AppNode, GoalNode, NormNode, - PhaseNode + PhaseNode, TriggerNode } from "./VisProgTypes.tsx"; +import type {TriggerNodeProps} from "./components/TriggerNodeComponent.tsx"; /** * Reduces the current graph inside the visual programming editor into a BehaviorProgram @@ -31,13 +32,15 @@ import type { * @param {PhaseReducer} phaseReducer * @param {NormReducer} normReducer * @param {GoalReducer} goalReducer + * @param {TriggerReducer} triggerReducer * @returns {BehaviorProgram} */ export default function graphReducer( graphPreprocessor: GraphPreprocessor = defaultGraphPreprocessor, phaseReducer: PhaseReducer = defaultPhaseReducer, normReducer: NormReducer = defaultNormReducer, - goalReducer: GoalReducer = defaultGoalReducer + goalReducer: GoalReducer = defaultGoalReducer, + triggerReducer: TriggerReducer = defaultTriggerReducer, ) : BehaviorProgram { const nodes: AppNode[] = useFlowStore.getState().nodes; const edges: Edge[] = useFlowStore.getState().edges; @@ -47,7 +50,8 @@ export default function graphReducer( phaseReducer( preparedPhase, normReducer, - goalReducer + goalReducer, + triggerReducer, )); }; @@ -58,12 +62,14 @@ export default function graphReducer( * @param {PreparedPhase} phase * @param {NormReducer} normReducer * @param {GoalReducer} goalReducer + * @param {TriggerReducer} triggerReducer * @returns {Phase} */ export function defaultPhaseReducer( phase: PreparedPhase, normReducer: NormReducer = defaultNormReducer, - goalReducer: GoalReducer = defaultGoalReducer + goalReducer: GoalReducer = defaultGoalReducer, + triggerReducer: TriggerReducer = defaultTriggerReducer, ) : Phase { return { id: phase.phaseNode.id, @@ -71,7 +77,8 @@ export function defaultPhaseReducer( nextPhaseId: phase.nextPhaseId, phaseData: { norms: phase.connectedNorms.map(normReducer), - goals: phase.connectedGoals.map(goalReducer) + goals: phase.connectedGoals.map(goalReducer), + triggers: phase.connectedTriggers.map(triggerReducer), } } } @@ -82,11 +89,12 @@ export function defaultPhaseReducer( * @param {GoalNode} node * @returns {GoalData} */ -function defaultGoalReducer(node: GoalNode) : GoalData { +function defaultGoalReducer(node: GoalNode) : Reduced { return { id: node.id, name: node.data.label, - value: node.data.value + description: node.data.description, + achieved: node.data.achieved, } } @@ -96,7 +104,7 @@ function defaultGoalReducer(node: GoalNode) : GoalData { * @param {NormNode} node * @returns {NormData} */ -function defaultNormReducer(node: NormNode) :NormData { +function defaultNormReducer(node: NormNode) :Reduced { return { id: node.id, name: node.data.label, @@ -104,6 +112,13 @@ function defaultNormReducer(node: NormNode) :NormData { } } +function defaultTriggerReducer(node: TriggerNode): Reduced { + return { + id: node.id, + ...node.data, + } +} + // Graph preprocessing functions: /** @@ -117,6 +132,7 @@ function defaultNormReducer(node: NormNode) :NormData { export function defaultGraphPreprocessor(nodes: AppNode[], edges: Edge[]) : PreparedGraph { const norms : NormNode[] = nodes.filter((node) => node.type === 'norm') as NormNode[]; const goals : GoalNode[] = nodes.filter((node) => node.type === 'goal') as GoalNode[]; + const triggers : TriggerNode[] = nodes.filter((node) => node.type === 'trigger') as TriggerNode[]; const orderedPhases : OrderedPhases = orderPhases(nodes, edges); return orderedPhases.phaseNodes.map((phase: PhaseNode) : PreparedPhase => { @@ -125,7 +141,8 @@ export function defaultGraphPreprocessor(nodes: AppNode[], edges: Edge[]) : Prep phaseNode: phase, nextPhaseId: nextPhase as string, connectedNorms: getIncomers({id: phase.id}, norms,edges), - connectedGoals: getIncomers({id: phase.id}, goals,edges) + connectedGoals: getIncomers({id: phase.id}, goals,edges), + connectedTriggers: getIncomers({id: phase.id}, triggers, edges), }; }); } diff --git a/src/pages/VisProgPage/visualProgrammingUI/GraphReducerTypes.ts b/src/pages/VisProgPage/visualProgrammingUI/GraphReducerTypes.ts index 9151b56..1826286 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/GraphReducerTypes.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/GraphReducerTypes.ts @@ -1,12 +1,14 @@ import type {Edge} from "@xyflow/react"; -import type {AppNode, GoalNode, NormNode, PhaseNode} from "./VisProgTypes.tsx"; +import type {AppNode, GoalNode, NormNode, PhaseNode, TriggerNode} from "./VisProgTypes.tsx"; +import type {TriggerNodeProps} from "./components/TriggerNodeComponent.tsx"; +export type Reduced = { id: string } & T; + /** * defines how a norm is represented in the simplified behavior program */ export type NormData = { - id: string; name: string; value: string; }; @@ -15,9 +17,9 @@ export type NormData = { * defines how a goal is represented in the simplified behavior program */ export type GoalData = { - id: string; name: string; - value: string; + description: string; + achieved: boolean; }; /** @@ -27,6 +29,7 @@ export type GoalData = { export type PhaseData = { norms: NormData[]; goals: GoalData[]; + triggers: TriggerNodeProps[]; }; /** @@ -55,12 +58,14 @@ export type BehaviorProgram = Phase[]; -export type NormReducer = (node: NormNode) => NormData; -export type GoalReducer = (node: GoalNode) => GoalData; +export type NormReducer = (node: NormNode) => Reduced; +export type GoalReducer = (node: GoalNode) => Reduced; +export type TriggerReducer = (node: TriggerNode) => Reduced; export type PhaseReducer = ( preparedPhase: PreparedPhase, normReducer: NormReducer, - goalReducer: GoalReducer + goalReducer: GoalReducer, + triggerReducer: TriggerReducer, ) => Phase; /** @@ -90,6 +95,7 @@ export type PreparedPhase = { nextPhaseId: string; connectedNorms: NormNode[]; connectedGoals: GoalNode[]; + connectedTriggers: TriggerNode[]; }; /** diff --git a/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx b/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx index bb7c28c..8bfc715 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx @@ -6,18 +6,21 @@ import { type OnConnect, type OnReconnect, } from '@xyflow/react'; +import type {TriggerNodeProps} from "./components/TriggerNodeComponent.tsx"; type defaultNodeData = { label: string; }; +type OurNode = Node; + export type StartNode = Node; export type EndNode = Node; -export type GoalNode = Node; +export type GoalNode = Node; export type NormNode = Node; export type PhaseNode = Node; - +export type TriggerNode = OurNode; /** * a type meant to house different node types, currently not used diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx index c9e1496..ea6b387 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx @@ -11,7 +11,7 @@ import { } from 'react'; import useFlowStore from "../VisProgStores.tsx"; import styles from "../../VisProg.module.css" -import type {AppNode, PhaseNode, NormNode} from "../VisProgTypes.tsx"; +import type {AppNode, PhaseNode, NormNode, GoalNode, TriggerNode} from "../VisProgTypes.tsx"; @@ -106,10 +106,48 @@ export function addNode(nodeType: string, position: XYPosition) { id: `norm-${normNumber}`, type: nodeType, position, - data: {label: `new norm node`, value: "Pepper should be formal"}, + data: {label: `new norm node`, value: ""}, } return normNode; } + case "goal": + { + const goalNodes= nds.filter((node) => node.type === 'goal'); + let goalNumber + if (goalNodes.length > 0) { + const finalGoalId : number = +(goalNodes[goalNodes.length - 1].id.split('-')[1]); + goalNumber = finalGoalId + 1; + } else { + goalNumber = 1; + } + + const goalNode : GoalNode = { + id: `goal-${goalNumber}`, + type: nodeType, + position, + data: {label: `new goal node`, description: "", achieved: false}, + } + return goalNode; + } + case "trigger": + { + const triggerNodes= nds.filter((node) => node.type === 'trigger'); + let triggerNumber + if (triggerNodes.length > 0) { + const finalGoalId : number = +(triggerNodes[triggerNodes.length - 1].id.split('-')[1]); + triggerNumber = finalGoalId + 1; + } else { + triggerNumber = 1; + } + + const triggerNode : TriggerNode = { + id: `trigger-${triggerNumber}`, + type: nodeType, + position, + data: {label: `new trigger node`, type: "keywords", value: []}, + } + return triggerNode; + } default: { throw new Error(`Node ${nodeType} not found`); } @@ -161,6 +199,12 @@ export function DndToolbar() { norm Node + + goal Node + + + trigger Node +
    ); diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/NodeDefinitions.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/NodeDefinitions.tsx index 19f56dd..3f7868d 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/NodeDefinitions.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/NodeDefinitions.tsx @@ -11,8 +11,9 @@ import type { StartNode, EndNode, PhaseNode, - NormNode + NormNode, GoalNode } from "../VisProgTypes.tsx"; +import {TextField} from "../../../../components/TextField.tsx"; //Toolbar definitions @@ -44,56 +45,6 @@ export function Toolbar({nodeId, allowDelete}: ToolbarProps) { ); } -// Renaming component - -/** - * Adds a component that can be used to edit a node's label entry inside its Data - * can be added to any custom node that has a label inside its Data - * - * @param {string} nodeLabel - * @param {string} nodeId - * @returns {React.JSX.Element} - * @constructor - */ -export function EditableName({nodeLabel = "new node", nodeId} : { nodeLabel : string, nodeId: string}) { - const {updateNodeData} = useFlowStore(); - - const updateData = (event: React.FocusEvent) => { - const input = event.target.value; - updateNodeData(nodeId, {label: input}); - event.currentTarget.setAttribute("readOnly", "true"); - window.getSelection()?.empty(); - event.currentTarget.classList.replace("nodrag", "drag"); // enable dragging of the node with cursor on the input box - }; - - const updateOnEnter = (event: React.KeyboardEvent) => { if (event.key === "Enter") (event.target as HTMLInputElement).blur(); }; - - const enableEditing = (event: React.MouseEvent) => { - if(event.currentTarget.hasAttribute("readOnly")) { - event.currentTarget.removeAttribute("readOnly"); // enable editing - event.currentTarget.select(); // select the text input - window.getSelection()?.collapseToEnd(); // move the caret to the end of the current value - event.currentTarget.classList.replace("drag", "nodrag"); // disable dragging using input box - } - } - - return ( -
    - - -
    - ) -} - // Definitions of Nodes @@ -148,11 +99,25 @@ export const EndNodeComponent = ({id, data}: NodeProps) => { * @constructor */ export const PhaseNodeComponent = ({id, data}: NodeProps) => { + const {updateNodeData} = useFlowStore(); + + const updateLabel = (value: string) => updateNodeData(id, {...data, label: value}); + + const label_input_id = `phase_${id}_label_input`; + return ( <>
    - +
    + + +
    @@ -167,17 +132,69 @@ export const PhaseNodeComponent = ({id, data}: NodeProps) => { * * @param {string} id * @param {defaultNodeData & {value: string}} data - * @returns {React.JSX.Element} - * @constructor */ export const NormNodeComponent = ({id, data}: NodeProps) => { - return ( - <> - -
    - - + const {updateNodeData} = useFlowStore(); + + const text_input_id = `norm_${id}_text_input`; + + const setValue = (value: string) => { + updateNodeData(id, {value: value}); + } + + return <> + +
    +
    + + setValue(val)} + placeholder={"Pepper should ..."} + />
    - - ); -}; \ No newline at end of file + +
    + ; +}; + +export const GoalNodeComponent = ({id, data}: NodeProps) => { + const {updateNodeData} = useFlowStore(); + + const text_input_id = `goal_${id}_text_input`; + const checkbox_id = `goal_${id}_checkbox`; + + const setDescription = (value: string) => { + updateNodeData(id, {...data, description: value}); + } + + const setAchieved = (value: boolean) => { + updateNodeData(id, {...data, achieved: value}); + } + + return <> + +
    +
    + + setDescription(val)} + placeholder={"To ..."} + /> +
    +
    + + setAchieved(e.target.checked)} + /> +
    + +
    + ; +} diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/TriggerNodeComponent.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/TriggerNodeComponent.tsx new file mode 100644 index 0000000..7fca1ff --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/components/TriggerNodeComponent.tsx @@ -0,0 +1,121 @@ +import {Handle, type NodeProps, Position} from "@xyflow/react"; +import type {TriggerNode} from "../VisProgTypes.tsx"; +import useFlowStore from "../VisProgStores.tsx"; +import styles from "../../VisProg.module.css"; +import {RealtimeTextField, TextField} from "../../../../components/TextField.tsx"; +import {Toolbar} from "./NodeDefinitions.tsx"; +import {useState} from "react"; +import duplicateIndices from "../../../../utils/duplicateIndices.ts"; + +export type EmotionTriggerNodeProps = { + type: "emotion"; + value: string; +} + +type Keyword = { id: string, keyword: string }; + +export type KeywordTriggerNodeProps = { + type: "keywords"; + value: Keyword[]; +} + +export type TriggerNodeProps = EmotionTriggerNodeProps | KeywordTriggerNodeProps; + +function KeywordAdder({ addKeyword }: { addKeyword: (keyword: string) => void }) { + const [input, setInput] = useState(""); + + const text_input_id = "keyword_adder_input"; + + return
    + + { + if (!input) return; + addKeyword(input); + setInput(""); + }} + placeholder={"..."} + className={"flex-1"} + /> +
    ; +} + +function Keywords({ + keywords, + setKeywords, +}: { + keywords: Keyword[]; + setKeywords: (keywords: Keyword[]) => void; +}) { + type Interpolatable = string | number | boolean | bigint | null | undefined; + + const inputElementId = (id: Interpolatable) => `keyword_${id}_input`; + + /** Indices of duplicates in the keyword array. */ + const [duplicates, setDuplicates] = useState([]); + + function replace(id: string, value: string) { + value = value.trim(); + const newKeywords = value === "" + ? keywords.filter((kw) => kw.id != id) + : keywords.map((kw) => kw.id === id ? {...kw, keyword: value} : kw); + setKeywords(newKeywords); + setDuplicates(duplicateIndices(newKeywords.map((kw) => kw.keyword))); + } + + function add(value: string) { + value = value.trim(); + if (value === "") return; + const newKeywords = [...keywords, {id: crypto.randomUUID(), keyword: value}]; + setKeywords(newKeywords); + setDuplicates(duplicateIndices(newKeywords.map((kw) => kw.keyword))); + } + + return <> + Triggers when {keywords.length <= 1 ? "the keyword is" : "all keywords are"} spoken. + {[...keywords].map(({id, keyword}, index) => { + return
    + + replace(id, val)} + placeholder={"..."} + className={"flex-1"} + invalid={duplicates.includes(index)} + /> +
    ; + })} + + ; +} + +export default function TriggerNodeComponent({ + id, + data, +}: NodeProps) { + const {updateNodeData} = useFlowStore(); + + const setKeywords = (keywords: Keyword[]) => { + updateNodeData(id, {...data, value: keywords}); + } + + return <> + +
    + {data.type === "emotion" && ( +
    Emotion?
    + )} + {data.type === "keywords" && ( + + )} + +
    + ; +} diff --git a/src/utils/duplicateIndices.ts b/src/utils/duplicateIndices.ts new file mode 100644 index 0000000..08a4d43 --- /dev/null +++ b/src/utils/duplicateIndices.ts @@ -0,0 +1,19 @@ +/** + * Find the indices of all elements that occur more than once. + * + * @param array The array to search for duplicates. + * @returns An array of indices where an element occurs more than once, in no particular order. + */ +export default function duplicateIndices(array: T[]): number[] { + const positions = new Map(); + + array.forEach((value, i) => { + if (!positions.has(value)) positions.set(value, []); + positions.get(value)!.push(i); + }); + + // flatten all index lists with more than one element + return Array.from(positions.values()) + .filter(idxs => idxs.length > 1) + .flat(); +} diff --git a/test/pages/visProgPage/visualProgrammingUI/GraphReducer.test.ts b/test/pages/visProgPage/visualProgrammingUI/GraphReducer.test.ts index 4473b82..246972c 100644 --- a/test/pages/visProgPage/visualProgrammingUI/GraphReducer.test.ts +++ b/test/pages/visProgPage/visualProgrammingUI/GraphReducer.test.ts @@ -457,6 +457,7 @@ describe('Graph Reducer Tests', () => { nextPhaseId: 'end', connectedNorms: [], connectedGoals: [], + connectedTriggers: [], }] }, { @@ -472,6 +473,7 @@ describe('Graph Reducer Tests', () => { nextPhaseId: 'phase-2', connectedNorms: [], connectedGoals: [], + connectedTriggers: [], }, { phaseNode: { @@ -483,6 +485,7 @@ describe('Graph Reducer Tests', () => { nextPhaseId: 'phase-3', connectedNorms: [], connectedGoals: [], + connectedTriggers: [], }, { phaseNode: { @@ -494,6 +497,7 @@ describe('Graph Reducer Tests', () => { nextPhaseId: 'end', connectedNorms: [], connectedGoals: [], + connectedTriggers: [], }] }, { @@ -509,6 +513,7 @@ describe('Graph Reducer Tests', () => { nextPhaseId: 'phase-2', connectedNorms: [], connectedGoals: [], + connectedTriggers: [], }, { phaseNode: { @@ -525,6 +530,7 @@ describe('Graph Reducer Tests', () => { data: {label: 'Generic Norm', value: "generic"}, }], connectedGoals: [], + connectedTriggers: [], }, { phaseNode: { @@ -541,6 +547,7 @@ describe('Graph Reducer Tests', () => { data: {label: 'Generic Norm', value: "generic"}, }], connectedGoals: [], + connectedTriggers: [], }] }, { @@ -561,6 +568,7 @@ describe('Graph Reducer Tests', () => { data: {label: 'Generic Norm', value: "generic"}, }], connectedGoals: [], + connectedTriggers: [], }, { phaseNode: { @@ -583,6 +591,7 @@ describe('Graph Reducer Tests', () => { data: {label: 'Generic Norm', value: "generic"}, }], connectedGoals: [], + connectedTriggers: [], }, { phaseNode: { @@ -605,6 +614,7 @@ describe('Graph Reducer Tests', () => { data: {label: 'Generic Norm', value: "generic"}, }], connectedGoals: [], + connectedTriggers: [], }] }, { @@ -732,6 +742,7 @@ describe('Graph Reducer Tests', () => { nextPhaseId: 'end', connectedNorms: [], connectedGoals: [], + connectedTriggers: [], } const output = defaultPhaseReducer(input); expect(output).toEqual({ @@ -740,7 +751,8 @@ describe('Graph Reducer Tests', () => { nextPhaseId: 'end', phaseData: { norms: [], - goals: [] + goals: [], + triggers: [], } }); }); @@ -760,6 +772,7 @@ describe('Graph Reducer Tests', () => { data: {label: 'Generic Norm', value: "generic"}, }], connectedGoals: [], + connectedTriggers: [], } const output = defaultPhaseReducer(input); expect(output).toEqual({ @@ -772,7 +785,8 @@ describe('Graph Reducer Tests', () => { name: 'Generic Norm', value: "generic" }], - goals: [] + goals: [], + triggers: [], } }); }); @@ -790,8 +804,9 @@ describe('Graph Reducer Tests', () => { id: 'goal-1', type: 'goal', position: {x: 0, y: 150}, - data: {label: 'Generic Goal', value: "generic"}, + data: {label: 'Generic Goal', description: "generic", achieved: false}, }], + connectedTriggers: [], } const output = defaultPhaseReducer(input); expect(output).toEqual({ @@ -803,7 +818,50 @@ describe('Graph Reducer Tests', () => { goals: [{ id: 'goal-1', name: 'Generic Goal', - value: "generic" + description: "generic", + achieved: false, + }], + triggers: [], + } + }); + }); + test("defaultTriggerReducer reduces triggers correctly", () => { + const input : PreparedPhase = { + phaseNode: { + id: 'phase-1', + type: 'phase', + position: {x: 0, y: 150}, + data: {label: 'Generic Phase', number: 1}, + }, + nextPhaseId: 'end', + connectedNorms: [], + connectedGoals: [], + connectedTriggers: [{ + id: 'trigger-1', + type: 'trigger', + position: {x: 0, y: 150}, + data: {label: 'Keyword Trigger', type: "keywords", value: [ + {id: "some_id", keyword: "generic"}, + {id: "another_id", keyword: "another"}, + ]}, + }], + } + const output = defaultPhaseReducer(input); + expect(output).toEqual({ + id: 'phase-1', + name: 'Generic Phase', + nextPhaseId: 'end', + phaseData: { + norms: [], + goals: [], + triggers: [{ + id: 'trigger-1', + label: 'Keyword Trigger', + type: "keywords", + value: [ + {id: "some_id", keyword: "generic"}, + {id: "another_id", keyword: "another"}, + ] }] } }); @@ -820,7 +878,8 @@ describe('Graph Reducer Tests', () => { nextPhaseId: 'end', phaseData: { norms: [], - goals: [] + goals: [], + triggers: [], } }] }, @@ -833,7 +892,8 @@ describe('Graph Reducer Tests', () => { nextPhaseId: 'phase-2', phaseData: { norms: [], - goals: [] + goals: [], + triggers: [], } }, { @@ -842,7 +902,8 @@ describe('Graph Reducer Tests', () => { nextPhaseId: 'phase-3', phaseData: { norms: [], - goals: [] + goals: [], + triggers: [], } }, { @@ -851,7 +912,8 @@ describe('Graph Reducer Tests', () => { nextPhaseId: 'end', phaseData: { norms: [], - goals: [] + goals: [], + triggers: [], } }] }, @@ -864,7 +926,8 @@ describe('Graph Reducer Tests', () => { nextPhaseId: 'phase-2', phaseData: { norms: [], - goals: [] + goals: [], + triggers: [], } }, { @@ -879,7 +942,8 @@ describe('Graph Reducer Tests', () => { value: "generic" } ], - goals: [] + goals: [], + triggers: [], } }, { @@ -892,7 +956,8 @@ describe('Graph Reducer Tests', () => { name: 'Generic Norm', value: "generic" }], - goals: [] + goals: [], + triggers: [], } }] }, @@ -909,7 +974,8 @@ describe('Graph Reducer Tests', () => { name: 'Generic Norm', value: "generic" }], - goals: [] + goals: [], + triggers: [], } }, { @@ -929,7 +995,8 @@ describe('Graph Reducer Tests', () => { value: "generic" } ], - goals: [] + goals: [], + triggers: [], } }, { @@ -947,7 +1014,8 @@ describe('Graph Reducer Tests', () => { name: 'Generic Norm', value: "generic" }], - goals: [] + goals: [], + triggers: [], } }] }, diff --git a/test/utils/duplicateIndices.test.ts b/test/utils/duplicateIndices.test.ts new file mode 100644 index 0000000..25dce1a --- /dev/null +++ b/test/utils/duplicateIndices.test.ts @@ -0,0 +1,22 @@ +import duplicateIndices from "../../src/utils/duplicateIndices.ts"; + +describe("duplicateIndices (unit)", () => { + it("returns an empty array for empty input", () => { + expect(duplicateIndices([])).toEqual([]); + }); + + it("returns an empty array when no duplicates exist", () => { + expect(duplicateIndices([1, 2, 3, 4])).toEqual([]); + }); + + it("returns all positions for every duplicated value", () => { + const result = duplicateIndices(["a", "b", "a", "c", "b", "b"]); + expect(result.sort()).toEqual([0, 1, 2, 4, 5]); + }); + + it("only treats identical references as duplicate objects", () => { + const shared = { v: 1 }; + const result = duplicateIndices([shared, { v: 1 }, shared, shared]); + expect(result.sort()).toEqual([0, 2, 3]); + }); +}); -- 2.49.1 From 2f7a48415bdac4f6a69799b19ae6494bd805b779 Mon Sep 17 00:00:00 2001 From: "Gerla, J. (Justin)" Date: Fri, 14 Nov 2025 11:46:44 +0000 Subject: [PATCH 097/184] refactor: removed unnecessary else blocks in orderPhases --- .../visualProgrammingUI/GraphReducer.ts | 35 +++++++++---------- 1 file changed, 16 insertions(+), 19 deletions(-) diff --git a/src/pages/VisProgPage/visualProgrammingUI/GraphReducer.ts b/src/pages/VisProgPage/visualProgrammingUI/GraphReducer.ts index 6a4ee55..3d85216 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/GraphReducer.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/GraphReducer.ts @@ -167,32 +167,29 @@ export function orderPhases(nodes: AppNode[],edges: Edge[]) : OrderedPhases { ) : OrderedPhases => { // get the current phase and the next phases; const currentPhase = phases[currentIndex]; - const nextPhaseNodes = getOutgoers(currentPhase,phaseNodes,edges); - const nextNodes = getOutgoers(currentPhase,nodes, edges); + const nextPhaseNodes = getOutgoers(currentPhase, phaseNodes, edges); + const nextNodes = getOutgoers(currentPhase, nodes, edges); // handles adding of the next phase to the chain, and error handle if an invalid state is received if (nextPhaseNodes.length === 1 && nextNodes.length === 1) { connections.set(currentPhase.id, nextPhaseNodes[0].id); return nextPhase(phases.push(nextPhaseNodes[0] as PhaseNode) - 1, {phaseNodes: phases, connections: connections}); - } else { - // handle erroneous states - if (nextNodes.length === 0){ - throw new Error(`| INVALID PROGRAM | the source handle of "${currentPhase.id}" doesn't have any outgoing connections`); - } else { - if (nextNodes.length > 1) { - throw new Error(`| INVALID PROGRAM | the source handle of "${currentPhase.id}" connects to too many targets`); - } else { - if (nextNodes[0].type === "end"){ - connections.set(currentPhase.id, "end"); - // returns the final output of the function - return { phaseNodes: phases, connections: connections}; - } else { - throw new Error(`| INVALID PROGRAM | the node "${nextNodes[0].id}" that "${currentPhase.id}" connects to is not a phase or end node`); - } - } - } } + // handle erroneous states + if (nextNodes.length === 0) { + throw new Error(`| INVALID PROGRAM | the source handle of "${currentPhase.id}" doesn't have any outgoing connections`); + } + if (nextNodes.length > 1) { + throw new Error(`| INVALID PROGRAM | the source handle of "${currentPhase.id}" connects to too many targets`); + } + if (nextNodes[0].type === "end") { + connections.set(currentPhase.id, "end"); + // returns the final output of the function + return {phaseNodes: phases, connections: connections}; + } + throw new Error(`| INVALID PROGRAM | the node "${nextNodes[0].id}" that "${currentPhase.id}" connects to is not a phase or end node`); } + // initializes the Map describing the connections between phase nodes // we need this Map to make sure we preserve this information, // so we don't need to do checks on the entire set of edges in further stages of the reduction algorithm -- 2.49.1 From c5dc825ca3dec31227f9760c8a86033daa50e8ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Mon, 17 Nov 2025 14:25:01 +0100 Subject: [PATCH 098/184] refactor: Initial working framework of node encapsulation works- polymorphic implementation of nodes in creating and connecting calls correct functions ref: N25B-294 --- src/pages/VisProgPage/VisProg.module.css | 2 +- src/pages/VisProgPage/VisProg.tsx | 22 +- .../visualProgrammingUI/GraphReducer.ts | 194 +--------------- .../visualProgrammingUI/GraphReducerTypes.ts | 106 --------- .../visualProgrammingUI/NodeRegistry.ts | 33 +++ .../visualProgrammingUI/VisProgStores.tsx | 215 ++++++++---------- .../visualProgrammingUI/VisProgTypes.tsx | 41 +--- .../components/DragDropSidebar.tsx | 185 +++++++-------- .../visualProgrammingUI/nodes/EndNode.tsx | 73 ++++++ .../{components => nodes}/NodeDefinitions.tsx | 101 +------- .../visualProgrammingUI/nodes/NormNode.tsx | 86 +++++++ .../visualProgrammingUI/nodes/PhaseNode.tsx | 116 ++++++++++ .../visualProgrammingUI/nodes/StartNode.tsx | 93 ++++++++ 13 files changed, 605 insertions(+), 662 deletions(-) delete mode 100644 src/pages/VisProgPage/visualProgrammingUI/GraphReducerTypes.ts create mode 100644 src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts create mode 100644 src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx rename src/pages/VisProgPage/visualProgrammingUI/{components => nodes}/NodeDefinitions.tsx (53%) create mode 100644 src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx create mode 100644 src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx create mode 100644 src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx diff --git a/src/pages/VisProgPage/VisProg.module.css b/src/pages/VisProgPage/VisProg.module.css index c58d0f3..8c6f70c 100644 --- a/src/pages/VisProgPage/VisProg.module.css +++ b/src/pages/VisProgPage/VisProg.module.css @@ -77,7 +77,7 @@ } .node-norm { - outline: forestgreen solid 2pt; + outline: rgb(0, 149, 25) solid 2pt; filter: drop-shadow(0 0 0.25rem forestgreen); } diff --git a/src/pages/VisProgPage/VisProg.tsx b/src/pages/VisProgPage/VisProg.tsx index 8208a70..17f4821 100644 --- a/src/pages/VisProgPage/VisProg.tsx +++ b/src/pages/VisProgPage/VisProg.tsx @@ -8,30 +8,16 @@ import { } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; import {useShallow} from 'zustand/react/shallow'; - -import { - StartNodeComponent, - EndNodeComponent, - PhaseNodeComponent, - NormNodeComponent -} from './visualProgrammingUI/components/NodeDefinitions.tsx'; import {DndToolbar} from './visualProgrammingUI/components/DragDropSidebar.tsx'; -import graphReducer from "./visualProgrammingUI/GraphReducer.ts"; import useFlowStore from './visualProgrammingUI/VisProgStores.tsx'; import type {FlowState} from './visualProgrammingUI/VisProgTypes.tsx'; import styles from './VisProg.module.css' +import type { JSX } from 'react'; +import { NodeTypes } from './visualProgrammingUI/NodeRegistry.ts'; +import { graphReducer } from './visualProgrammingUI/GraphReducer.ts'; // --| config starting params for flow |-- -/** - * contains the types of all nodes that are available in the editor - */ -const NODE_TYPES = { - start: StartNodeComponent, - end: EndNodeComponent, - phase: PhaseNodeComponent, - norm: NormNodeComponent -}; /** * defines how the default edge looks inside the editor @@ -86,7 +72,7 @@ const VisProgUI = () => { nodes={nodes} edges={edges} defaultEdgeOptions={DEFAULT_EDGE_OPTIONS} - nodeTypes={NODE_TYPES} + nodeTypes={NodeTypes} onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} onReconnect={onReconnect} diff --git a/src/pages/VisProgPage/visualProgrammingUI/GraphReducer.ts b/src/pages/VisProgPage/visualProgrammingUI/GraphReducer.ts index 138eb82..ae846d7 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/GraphReducer.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/GraphReducer.ts @@ -1,188 +1,16 @@ -import { - type Edge, - getIncomers, - getOutgoers -} from '@xyflow/react'; -import useFlowStore from "./VisProgStores.tsx"; -import type { - BehaviorProgram, - GoalData, - GoalReducer, - GraphPreprocessor, - NormData, - NormReducer, - OrderedPhases, - Phase, - PhaseReducer, - PreparedGraph, - PreparedPhase -} from "./GraphReducerTypes.ts"; -import type { - AppNode, - GoalNode, - NormNode, - PhaseNode -} from "./VisProgTypes.tsx"; +import useFlowStore from './VisProgStores'; +import { NodeReduces } from './NodeRegistry' /** - * Reduces the current graph inside the visual programming editor into a BehaviorProgram - * - * @param {GraphPreprocessor} graphPreprocessor - * @param {PhaseReducer} phaseReducer - * @param {NormReducer} normReducer - * @param {GoalReducer} goalReducer - * @returns {BehaviorProgram} + * Reduces a graph by reducing each of its phases down + * @returns an array of the reduced data types. */ -export default function graphReducer( - graphPreprocessor: GraphPreprocessor = defaultGraphPreprocessor, - phaseReducer: PhaseReducer = defaultPhaseReducer, - normReducer: NormReducer = defaultNormReducer, - goalReducer: GoalReducer = defaultGoalReducer -) : BehaviorProgram { - const nodes: AppNode[] = useFlowStore.getState().nodes; - const edges: Edge[] = useFlowStore.getState().edges; - const preparedGraph: PreparedGraph = graphPreprocessor(nodes, edges); - - return preparedGraph.map((preparedPhase: PreparedPhase) : Phase => - phaseReducer( - preparedPhase, - normReducer, - goalReducer - )); -}; - -/** - * reduces a single preparedPhase to a Phase object - * the Phase object describes a single phase in a BehaviorProgram - * - * @param {PreparedPhase} phase - * @param {NormReducer} normReducer - * @param {GoalReducer} goalReducer - * @returns {Phase} - */ -export function defaultPhaseReducer( - phase: PreparedPhase, - normReducer: NormReducer = defaultNormReducer, - goalReducer: GoalReducer = defaultGoalReducer -) : Phase { - return { - id: phase.phaseNode.id, - name: phase.phaseNode.data.label, - nextPhaseId: phase.nextPhaseId, - phaseData: { - norms: phase.connectedNorms.map(normReducer), - goals: phase.connectedGoals.map(goalReducer) - } - } -} - -/** - * the default implementation of the goalNode reducer function - * - * @param {GoalNode} node - * @returns {GoalData} - */ -function defaultGoalReducer(node: GoalNode) : GoalData { - return { - id: node.id, - name: node.data.label, - value: node.data.value - } -} - -/** - * the default implementation of the normNode reducer function - * - * @param {NormNode} node - * @returns {NormData} - */ -function defaultNormReducer(node: NormNode) :NormData { - return { - id: node.id, - name: node.data.label, - value: node.data.value - } -} - -// Graph preprocessing functions: - -/** - * Preprocesses the provide state of the behavior editor graph, preparing it for further processing in - * the graphReducer function - * - * @param {AppNode[]} nodes - * @param {Edge[]} edges - * @returns {PreparedGraph} - */ -export function defaultGraphPreprocessor(nodes: AppNode[], edges: Edge[]) : PreparedGraph { - const norms : NormNode[] = nodes.filter((node) => node.type === 'norm') as NormNode[]; - const goals : GoalNode[] = nodes.filter((node) => node.type === 'goal') as GoalNode[]; - const orderedPhases : OrderedPhases = orderPhases(nodes, edges); - - return orderedPhases.phaseNodes.map((phase: PhaseNode) : PreparedPhase => { - const nextPhase = orderedPhases.connections.get(phase.id); - return { - phaseNode: phase, - nextPhaseId: nextPhase as string, - connectedNorms: getIncomers({id: phase.id}, norms,edges), - connectedGoals: getIncomers({id: phase.id}, goals,edges) - }; +export function graphReducer() { + const { nodes } = useFlowStore.getState(); + return nodes + .filter((n) => n.type == 'phase') + .map((n) => { + const reducer = NodeReduces['phase']; + return reducer(n, nodes) }); -} - -/** - * orderPhases takes the state of the graph created by the editor and turns it into an OrderedPhases object. - * - * @param {AppNode[]} nodes - * @param {Edge[]} edges - * @returns {OrderedPhases} - */ -export function orderPhases(nodes: AppNode[],edges: Edge[]) : OrderedPhases { - // find the first Phase node - const phaseNodes : PhaseNode[] = nodes.filter((node) => node.type === 'phase') as PhaseNode[]; - const startNodeIndex = nodes.findIndex((node : AppNode):boolean => {return (node.type === 'start');}); - const firstPhaseNode = getOutgoers({ id: nodes[startNodeIndex].id },phaseNodes,edges); - - // recursively adds the phase nodes to a list in the order they are connected in the graph - const nextPhase = ( - currentIndex: number, - { phaseNodes: phases, connections: connections} : OrderedPhases - ) : OrderedPhases => { - // get the current phase and the next phases; - const currentPhase = phases[currentIndex]; - const nextPhaseNodes = getOutgoers(currentPhase,phaseNodes,edges); - const nextNodes = getOutgoers(currentPhase,nodes, edges); - - // handles adding of the next phase to the chain, and error handle if an invalid state is received - if (nextPhaseNodes.length === 1 && nextNodes.length === 1) { - connections.set(currentPhase.id, nextPhaseNodes[0].id); - return nextPhase(phases.push(nextPhaseNodes[0] as PhaseNode) - 1, {phaseNodes: phases, connections: connections}); - } else { - // handle erroneous states - if (nextNodes.length === 0){ - throw new Error(`| INVALID PROGRAM | the source handle of "${currentPhase.id}" doesn't have any outgoing connections`); - } else { - if (nextNodes.length > 1) { - throw new Error(`| INVALID PROGRAM | the source handle of "${currentPhase.id}" connects to too many targets`); - } else { - if (nextNodes[0].type === "end"){ - connections.set(currentPhase.id, "end"); - // returns the final output of the function - return { phaseNodes: phases, connections: connections}; - } else { - throw new Error(`| INVALID PROGRAM | the node "${nextNodes[0].id}" that "${currentPhase.id}" connects to is not a phase or end node`); - } - } - } - } - } - // initializes the Map describing the connections between phase nodes - // we need this Map to make sure we preserve this information, - // so we don't need to do checks on the entire set of edges in further stages of the reduction algorithm - const connections : Map = new Map(); - - // returns an empty list if no phase nodes are present, otherwise returns an ordered list of phaseNodes - if (firstPhaseNode.length > 0) { - return nextPhase(0, {phaseNodes: [firstPhaseNode[0] as PhaseNode], connections: connections}) - } else { return {phaseNodes: [], connections: connections} } } \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/GraphReducerTypes.ts b/src/pages/VisProgPage/visualProgrammingUI/GraphReducerTypes.ts deleted file mode 100644 index 9151b56..0000000 --- a/src/pages/VisProgPage/visualProgrammingUI/GraphReducerTypes.ts +++ /dev/null @@ -1,106 +0,0 @@ -import type {Edge} from "@xyflow/react"; -import type {AppNode, GoalNode, NormNode, PhaseNode} from "./VisProgTypes.tsx"; - - -/** - * defines how a norm is represented in the simplified behavior program - */ -export type NormData = { - id: string; - name: string; - value: string; -}; - -/** - * defines how a goal is represented in the simplified behavior program - */ -export type GoalData = { - id: string; - name: string; - value: string; -}; - -/** - * definition of a PhaseData object, it contains all phaseData that is relevant - * for further processing and execution of a phase. - */ -export type PhaseData = { - norms: NormData[]; - goals: GoalData[]; -}; - -/** - * Describes a single phase within the simplified representation of a behavior program, - * - * Contains: - * - the id of the described phase, - * - the name of the described phase, - * - the id of the next phase in the user defined behavior program - * - the data property of the described phase node - * - * @NOTE at the moment the type definitions do not support branching programs, - * if branching of phases is to be supported in the future, the type definition for Phase has to be updated - */ -export type Phase = { - id: string; - name: string; - nextPhaseId: string; - phaseData: PhaseData; -}; - -/** - * Describes a simplified behavior program as a list of Phase objects - */ -export type BehaviorProgram = Phase[]; - - - -export type NormReducer = (node: NormNode) => NormData; -export type GoalReducer = (node: GoalNode) => GoalData; -export type PhaseReducer = ( - preparedPhase: PreparedPhase, - normReducer: NormReducer, - goalReducer: GoalReducer -) => Phase; - -/** - * contains: - * - * - list of phases, sorted based on position in chain between the start and end node - * - a dictionary containing all outgoing connections, - * to other phase or end nodes, for each phase node uses the id of the source node as key - * and the id of the target node as value - * - */ -export type OrderedPhases = { - phaseNodes: PhaseNode[]; - connections: Map; -}; - -/** - * A single prepared phase, - * contains: - * - the described phaseNode, - * - the id of the next phaseNode or "end" for the end node - * - a list of the normNodes that are connected to the described phase - * - a list of the goalNodes that are connected to the described phase - */ -export type PreparedPhase = { - phaseNode: PhaseNode; - nextPhaseId: string; - connectedNorms: NormNode[]; - connectedGoals: GoalNode[]; -}; - -/** - * a list of PreparedPhase objects, - * describes the preprocessed state of a program, - * before the contents of the node - */ -export type PreparedGraph = PreparedPhase[]; - -export type GraphPreprocessor = (nodes: AppNode[], edges: Edge[]) => PreparedGraph; - - - - diff --git a/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts b/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts new file mode 100644 index 0000000..12202f1 --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts @@ -0,0 +1,33 @@ +import StartNode, { StartConnects, StartNodeDefaults, StartReduce } from "./nodes/StartNode"; +import EndNode, { EndConnects, EndNodeDefaults, EndReduce } from "./nodes/EndNode"; +import PhaseNode, { PhaseConnects, PhaseNodeDefaults, PhaseReduce } from "./nodes/PhaseNode"; +import NormNode, { NormConnects, NormNodeDefaults, NormReduce } from "./nodes/NormNode"; + +export const NodeTypes = { + start: StartNode, + end: EndNode, + phase: PhaseNode, + norm: NormNode, +}; + +// Default node data for creation +export const NodeDefaults = { + start: StartNodeDefaults, + end: EndNodeDefaults, + phase: PhaseNodeDefaults, + norm: NormNodeDefaults, +}; + +export const NodeReduces = { + start: StartReduce, + end: EndReduce, + phase: PhaseReduce, + norm: NormReduce, +} + +export const NodeConnects = { + start: StartConnects, + end: EndConnects, + phase: PhaseConnects, + norm: NormConnects, +} \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx index 300c14b..1368d5d 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx @@ -1,142 +1,127 @@ -import {create} from 'zustand'; +import { create } from 'zustand'; import { applyNodeChanges, applyEdgeChanges, addEdge, - reconnectEdge, type Edge, type Connection + reconnectEdge, + type Node, + type Edge, + type NodeChange, + type XYPosition, } from '@xyflow/react'; +import type { FlowState, AppNode } from './VisProgTypes'; +import { NodeDefaults, NodeConnects } from './NodeRegistry'; -import {type AppNode, type FlowState} from './VisProgTypes.tsx'; /** - * contains the nodes that are created when the editor is loaded, - * should contain at least a start and an end node + * Create a node given the correct data + * @param type + * @param id + * @param position + * @param data + * @constructor */ -const initialNodes = [ - { - id: 'start', - type: 'start', - position: {x: 0, y: 0}, - data: {label: 'start'} - }, - { - id: 'phase-1', - type: 'phase', - position: {x: 0, y: 150}, - data: {label: 'Generic Phase', number: 1}, - }, - { - id: 'end', - type: 'end', - position: {x: 0, y: 300}, - data: {label: 'End'} +function createNode(id: string, type: string, position: XYPosition, data: any) { + + const defaultData = Object.entries(NodeDefaults).find(([t, _]) => t == type)?.[1] + const newData = { + id: id, + type: type, + position: position, + data: data, } + + return (defaultData == undefined) ? newData : ({...defaultData, ...newData}) +} + +//* Initial nodes, created by using createNodeInstance. */ +const initialNodes : Node[] = [ + createNode('start', 'start', {x: 100, y: 100}, {label: "Start"}), + createNode('end', 'end', {x: 370, y: 100}, {label: "End"}), + createNode('phase-1', 'phase', {x:200, y:100}, {label: "Phase 1", children: ['end', 'start']}), + createNode('norms-1', 'norm', {x:-200, y:100}, {label: "Initial Norms", normList: ["Be a robot", "get good"]}), ]; -/** - * contains the initial edges that are created when the editor is loaded - */ -const initialEdges = [ - { - id: 'start-phase-1', - source: 'start', - target: 'phase-1', - }, - { - id: 'phase-1-end', - source: 'phase-1', - target: 'end', - } +// * Initial edges * / +const initialEdges: Edge[] = [ + { id: 'start-phase-1', source: 'start', target: 'phase-1' }, + { id: 'phase-1-end', source: 'phase-1', target: 'end' }, ]; -/** - * The useFlowStore hook contains the implementation for editor functionality and state - * we can use this inside our editor component to access the current state - * and use any implemented functionality - */ const useFlowStore = create((set, get) => ({ nodes: initialNodes, edges: initialEdges, edgeReconnectSuccessful: true, - onNodesChange: (changes) => { - set({ - nodes: applyNodeChanges(changes, get().nodes) - }); - }, - onEdgesChange: (changes) => { - set({ - edges: applyEdgeChanges(changes, get().edges) - }); - }, - // handles connection of newly created edges + + onNodesChange: (changes) => + set({nodes: applyNodeChanges(changes, get().nodes)}), + onEdgesChange: (changes) => set({ edges: applyEdgeChanges(changes, get().edges) }), + + // Let's make sure we tell the nodes when they're connected, and how it matters. onConnect: (connection) => { - set({ - edges: addEdge(connection, get().edges) - }); - }, - // handles attempted reconnections of a previously disconnected edge - onReconnect: (oldEdge: Edge, newConnection: Connection) => { + const edges = addEdge(connection, get().edges); + const nodes = get().nodes; + // connection has: { source, sourceHandle, target, targetHandle } + // Let's find the source and target ID's. + let sourceNode = nodes.find((n) => n.id == connection.source); + let targetNode = nodes.find((n) => n.id == connection.target); + + // In case the nodes weren't found, return basic functionality. + if (sourceNode == undefined || targetNode == undefined || sourceNode.type == undefined || targetNode.type == undefined) { + set({ nodes, edges }); + return; + } + + // We should find out how their data changes by calling their respective functions. + let sourceConnectFunction = Object.entries(NodeConnects).find(([t, _]) => t == sourceNode.type)?.[1] + let targetConnectFunction = Object.entries(NodeConnects).find(([t, _]) => t == targetNode.type)?.[1] + if (sourceConnectFunction == undefined || targetConnectFunction == undefined) { + set({ nodes, edges }); + return; + } + + // We're going to have to update their data based on how they want to update it. + sourceConnectFunction(sourceNode, targetNode, true) + targetConnectFunction(targetNode, sourceNode, false) + set({ nodes, edges }); +}, + + onReconnect: (oldEdge, newConnection) => { get().edgeReconnectSuccessful = true; - set({ - edges: reconnectEdge(oldEdge, newConnection, get().edges) - }); + set({ edges: reconnectEdge(oldEdge, newConnection, get().edges) }); }, - // Handles initiation of reconnection of edges that are manually disconnected from a node - onReconnectStart: () => { - set({ - edgeReconnectSuccessful: false - }); - }, - // Drops the edge from the set of edges, removing it from the flow, if no successful reconnection occurred - onReconnectEnd: (_: unknown, edge: { id: string; }) => { + + onReconnectStart: () => set({ edgeReconnectSuccessful: false }), + onReconnectEnd: (_evt, edge) => { if (!get().edgeReconnectSuccessful) { - set({ - edges: get().edges.filter((e) => e.id !== edge.id), - }); + set({ edges: get().edges.filter((e) => e.id !== edge.id) }); } - set({ - edgeReconnectSuccessful: true - }); + set({ edgeReconnectSuccessful: true }); }, - deleteNode: (nodeId: string) => { + + deleteNode: (nodeId) => set({ nodes: get().nodes.filter((n) => n.id !== nodeId), - edges: get().edges.filter((e) => e.source !== nodeId && e.target !== nodeId) - }); - }, - setNodes: (nodes) => { - set({nodes}); - }, - setEdges: (edges) => { - set({edges}); - }, -/** - * handles updating the data component of a node, - * if the provided data object contains entries that aren't present in the updated node's data component - * those entries are added to the data component, - * entries that do exist within the node's data component, - * are simply updated to contain the new value - * - * the data object - * @param {string} nodeId - * @param {object} data - */ - updateNodeData: (nodeId: string, data) => { - set({ - nodes: get().nodes.map((node) : AppNode => { - if (node.id === nodeId) { - return { - ...node, - data: { - ...node.data, - ...data - } - }; - } else { return node; } - }) - }); - } - }), -); + edges: get().edges.filter((e) => e.source !== nodeId && e.target !== nodeId), + }), -export default useFlowStore; \ No newline at end of file + setNodes: (nodes) => set({ nodes }), + setEdges: (edges) => set({ edges }), + + updateNodeData: (nodeId, data) => { + set({ + nodes: get().nodes.map((node) => { + if (node.id === nodeId) { + node.data = { ...node.data, ...data }; + } + return node; + }), + }); + }, + + addNode: (node: Node) => { + set({ nodes: [...get().nodes, node] }); + }, +})); + +export default useFlowStore; diff --git a/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx b/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx index bb7c28c..6b98d6b 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx @@ -1,47 +1,24 @@ -import { - type Edge, - type Node, - type OnNodesChange, - type OnEdgesChange, - type OnConnect, - type OnReconnect, -} from '@xyflow/react'; +// VisProgTypes.ts +import type { Edge, OnNodesChange, OnEdgesChange, OnConnect, OnReconnect, Node } from '@xyflow/react'; +import type { NodeTypes } from './NodeRegistry'; +export type AppNode = typeof NodeTypes -type defaultNodeData = { - label: string; -}; - -export type StartNode = Node; -export type EndNode = Node; -export type GoalNode = Node; -export type NormNode = Node; -export type PhaseNode = Node; - - -/** - * a type meant to house different node types, currently not used - * but will allow us to more clearly define nodeTypes when we implement - * computation of the Graph inside the ReactFlow editor - */ -export type AppNode = Node | StartNode | EndNode | NormNode | GoalNode | PhaseNode; - - -/** - * The type for the Zustand store object used to manage the state of the ReactFlow editor - */ export type FlowState = { - nodes: AppNode[]; + nodes: Node[]; edges: Edge[]; edgeReconnectSuccessful: boolean; + onNodesChange: OnNodesChange; onEdgesChange: OnEdgesChange; onConnect: OnConnect; onReconnect: OnReconnect; onReconnectStart: () => void; onReconnectEnd: (_: unknown, edge: { id: string }) => void; + deleteNode: (nodeId: string) => void; - setNodes: (nodes: AppNode[]) => void; + setNodes: (nodes: Node[]) => void; setEdges: (edges: Edge[]) => void; updateNodeData: (nodeId: string, data: object) => void; + addNode: (node: Node) => void; }; \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx index c9e1496..f34bd00 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx @@ -1,19 +1,10 @@ -import {useDraggable} from '@neodrag/react'; -import { - useReactFlow, - type XYPosition -} from '@xyflow/react'; -import { - type ReactNode, - useCallback, - useRef, - useState -} from 'react'; -import useFlowStore from "../VisProgStores.tsx"; -import styles from "../../VisProg.module.css" -import type {AppNode, PhaseNode, NormNode} from "../VisProgTypes.tsx"; - - +import { useDraggable } from '@neodrag/react'; +import { useReactFlow, type XYPosition } from '@xyflow/react'; +import { type ReactNode, useCallback, useRef, useState } from 'react'; +import useFlowStore from '../VisProgStores'; +import styles from '../../VisProg.module.css'; +import type { AppNode } from '../VisProgTypes'; +import { NodeDefaults, type NodeTypes } from '../NodeRegistry' /** * DraggableNodeProps dictates the type properties of a DraggableNode @@ -21,41 +12,28 @@ import type {AppNode, PhaseNode, NormNode} from "../VisProgTypes.tsx"; interface DraggableNodeProps { className?: string; children: ReactNode; - nodeType: string; - onDrop: (nodeType: string, position: XYPosition) => void; + nodeType: keyof typeof NodeTypes; + onDrop: (nodeType: keyof typeof NodeTypes, position: XYPosition) => void; } /** - * Definition of a node inside the drag and drop toolbar, - * these nodes require an onDrop function to be supplied - * that dictates how the node is created in the graph. - * - * @param className - * @param children - * @param nodeType - * @param onDrop - * @constructor + * Definition of a node inside the drag and drop toolbar. + * These nodes require an onDrop function that dictates + * how the node is created in the graph. */ -function DraggableNode({className, children, nodeType, onDrop}: DraggableNodeProps) { +function DraggableNode({ className, children, nodeType, onDrop }: DraggableNodeProps) { const draggableRef = useRef(null); - const [position, setPosition] = useState({x: 0, y: 0}); + const [position, setPosition] = useState({ x: 0, y: 0 }); - // @ts-expect-error comes from a package and doesn't appear to play nicely with strict typescript typing + // @ts-expect-error from the neodrag package — safe to ignore useDraggable(draggableRef, { - position: position, - onDrag: ({offsetX, offsetY}) => { - // Calculate position relative to the viewport - setPosition({ - x: offsetX, - y: offsetY, - }); + position, + onDrag: ({ offsetX, offsetY }) => { + setPosition({ x: offsetX, y: offsetY }); }, - onDragEnd: ({event}) => { - setPosition({x: 0, y: 0}); - onDrop(nodeType, { - x: event.clientX, - y: event.clientY, - }); + onDragEnd: ({ event }) => { + setPosition({ x: 0, y: 0 }); + onDrop(nodeType, { x: event.clientX, y: event.clientY }); }, }); @@ -66,71 +44,49 @@ function DraggableNode({className, children, nodeType, onDrop}: DraggableNodePro ); } +/** + * addNode — adds a new node to the flow using the unified class-based system. + * Keeps numbering logic for phase/norm nodes. + */ +export function addNode(nodeType: keyof typeof NodeTypes, position: XYPosition) { + const { nodes, setNodes } = useFlowStore.getState(); + const defaultData = NodeDefaults[nodeType] -// eslint-disable-next-line react-refresh/only-export-components -export function addNode(nodeType: string, position: XYPosition) { - const {setNodes} = useFlowStore.getState(); - const nds : AppNode[] = useFlowStore.getState().nodes; - const newNode = () => { - switch (nodeType) { - case "phase": - { - const phaseNodes= nds.filter((node) => node.type === 'phase'); - let phaseNumber; - if (phaseNodes.length > 0) { - const finalPhaseId : number = +(phaseNodes[phaseNodes.length - 1].id.split('-')[1]); - phaseNumber = finalPhaseId + 1; - } else { - phaseNumber = 1; - } - const phaseNode : PhaseNode = { - id: `phase-${phaseNumber}`, - type: nodeType, - position, - data: {label: 'new', number: phaseNumber}, - } - return phaseNode; - } - case "norm": - { - const normNodes= nds.filter((node) => node.type === 'norm'); - let normNumber - if (normNodes.length > 0) { - const finalNormId : number = +(normNodes[normNodes.length - 1].id.split('-')[1]); - normNumber = finalNormId + 1; - } else { - normNumber = 1; - } + if (!defaultData) throw new Error(`Node type '${nodeType}' not found in registry`); - const normNode : NormNode = { - id: `norm-${normNumber}`, - type: nodeType, - position, - data: {label: `new norm node`, value: "Pepper should be formal"}, - } - return normNode; - } - default: { - throw new Error(`Node ${nodeType} not found`); - } - } + const sameTypeNodes = nodes.filter((node) => node.type === nodeType); + const nextNumber = + sameTypeNodes.length > 0 + ? (() => { + const lastNode = sameTypeNodes[sameTypeNodes.length - 1]; + const parts = lastNode.id.split('-'); + const lastNum = Number(parts[1]); + return Number.isNaN(lastNum) ? sameTypeNodes.length + 1 : lastNum + 1; + })() + : 1; + + const id = `${nodeType}-${nextNumber}`; + + let newNode = { + id: id, + type: nodeType, + position, + data: {...defaultData} } - - setNodes(nds.concat(newNode())); + + console.log("Tried to add node"); + setNodes([...nodes, newNode]); } /** - * the DndToolbar defines how the drag and drop toolbar component works - * and includes the default onDrop behavior through handleNodeDrop - * @constructor + * DndToolbar defines how the drag and drop toolbar component works + * and includes the default onDrop behavior. */ export function DndToolbar() { - const {screenToFlowPosition} = useReactFlow(); - /** - * handleNodeDrop implements the default onDrop behavior - */ + const { screenToFlowPosition } = useReactFlow(); + const handleNodeDrop = useCallback( - (nodeType: string, screenPosition: XYPosition) => { + (nodeType: keyof typeof NodeTypes, screenPosition: XYPosition) => { const flow = document.querySelector('.react-flow'); const flowRect = flow?.getBoundingClientRect(); const isInFlow = @@ -140,7 +96,6 @@ export function DndToolbar() { screenPosition.y >= flowRect.top && screenPosition.y <= flowRect.bottom; - // Create a new node and add it to the flow if (isInFlow) { const position = screenToFlowPosition(screenPosition); addNode(nodeType, position); @@ -149,19 +104,35 @@ export function DndToolbar() { [screenToFlowPosition], ); + + const droppableNodes = Object.entries(NodeDefaults) + .filter(([_, data]) => data.droppable) + .map(([type, data]) => ({ + type: type as DraggableNodeProps['nodeType'], + data + })); + + + return (
    You can drag these nodes to the pane to create new nodes.
    - - phase Node - - - norm Node - + { + // Maps over all the nodes that are droppable, and puts them in the panel + } + {droppableNodes.map(({type, data}) => ( + + {data.label} + + ))}
    ); -} \ No newline at end of file +} diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx new file mode 100644 index 0000000..a00ad4e --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx @@ -0,0 +1,73 @@ +import { + Handle, + type NodeProps, + Position, + type Connection, + type Edge, + useReactFlow, + type Node, +} from '@xyflow/react'; +import { Toolbar } from './NodeDefinitions'; +import styles from '../../VisProg.module.css'; + +export type EndNodeData = { + label: string; + droppable: Boolean; + hasReduce: Boolean; +}; + + +export const EndNodeDefaults: EndNodeData = { + label: "End Node", + droppable: false, + hasReduce: true +}; + +export type EndNode = Node + +export function EndNodeCanConnect(connection: Connection | Edge): boolean { + // connection has: { source, sourceHandle, target, targetHandle } + + // Example rules: + if (connection.source === connection.target) return false; + + + if (connection.targetHandle && !["a", "b"].includes(connection.targetHandle)) { + return false; + } + + if (connection.sourceHandle && connection.sourceHandle !== "result") { + return false; + } + + // If all rules pass + return true; +} + +export default function EndNode(props: NodeProps) { + const reactFlow = useReactFlow(); + const label_input_id = `phase_${props.id}_label_input`; + return ( + <> + +
    +
    + End +
    + + + +
    + + ); +} + +export function EndReduce(node: Node, nodes: Node[]) { + return { + id: node.id + } +} + +export function EndConnects(thisNode: Node, otherNode: Node, isThisSource: boolean) { + +} \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/NodeDefinitions.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/NodeDefinitions.tsx similarity index 53% rename from src/pages/VisProgPage/visualProgrammingUI/components/NodeDefinitions.tsx rename to src/pages/VisProgPage/visualProgrammingUI/nodes/NodeDefinitions.tsx index 19f56dd..5367dff 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/NodeDefinitions.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/NodeDefinitions.tsx @@ -1,21 +1,10 @@ import { - Handle, - type NodeProps, - NodeToolbar, - Position -} from '@xyflow/react'; + NodeToolbar} from '@xyflow/react'; import '@xyflow/react/dist/style.css'; import styles from '../../VisProg.module.css'; import useFlowStore from "../VisProgStores.tsx"; -import type { - StartNode, - EndNode, - PhaseNode, - NormNode -} from "../VisProgTypes.tsx"; //Toolbar definitions - type ToolbarProps = { nodeId: string; allowDelete: boolean; @@ -45,7 +34,6 @@ export function Toolbar({nodeId, allowDelete}: ToolbarProps) { } // Renaming component - /** * Adds a component that can be used to edit a node's label entry inside its Data * can be added to any custom node that has a label inside its Data @@ -94,90 +82,3 @@ export function EditableName({nodeLabel = "new node", nodeId} : { nodeLabel : st ) } - -// Definitions of Nodes - -/** - * Start Node definition: - * - * @param {string} id - * @param {defaultNodeData} data - * @returns {React.JSX.Element} - * @constructor - */ -export const StartNodeComponent = ({id, data}: NodeProps) => { - return ( - <> - -
    -
    data test {data.label}
    - -
    - - ); -}; - - -/** - * End node definition: - * - * @param {string} id - * @param {defaultNodeData} data - * @returns {React.JSX.Element} - * @constructor - */ -export const EndNodeComponent = ({id, data}: NodeProps) => { - return ( - <> - -
    -
    {data.label}
    - -
    - - ); -}; - - -/** - * Phase node definition: - * - * @param {string} id - * @param {defaultNodeData & {number: number}} data - * @returns {React.JSX.Element} - * @constructor - */ -export const PhaseNodeComponent = ({id, data}: NodeProps) => { - return ( - <> - -
    - - - - -
    - - ); -}; - - -/** - * Norm node definition: - * - * @param {string} id - * @param {defaultNodeData & {value: string}} data - * @returns {React.JSX.Element} - * @constructor - */ -export const NormNodeComponent = ({id, data}: NodeProps) => { - return ( - <> - -
    - - -
    - - ); -}; \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx new file mode 100644 index 0000000..eefbfe6 --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx @@ -0,0 +1,86 @@ +import { + Handle, + type NodeProps, + Position, + type Connection, + type Edge, + useReactFlow, + type Node, +} from '@xyflow/react'; +import { Toolbar } from './NodeDefinitions'; +import styles from '../../VisProg.module.css'; +import { NodeDefaults, NodeReduces } from '../NodeRegistry'; +import type { FlowState } from '../VisProgTypes'; + +/** + * The default data dot a Norm node + * @param label: the label of this Norm + * @param droppable: whether this node is droppable from the drop bar (initialized as true) + * @param children: ID's of children of this node + */ +export type NormNodeData = { + label: string; + droppable: boolean; + normList: string[]; + hasReduce: boolean; +}; + +/** + * Default data for this node + */ +export const NormNodeDefaults: NormNodeData = { + label: "Norm Node", + droppable: true, + normList: [], + hasReduce: true, +}; + +export type NormNode = Node + +/** + * + * @param connection + * @returns + */ +export function NormNodeCanConnect(connection: Connection | Edge): boolean { + return true; +} + +/** + * Defines how a Norm node should be rendered + * @param props NodeProps, like id, label, children + * @returns React.JSX.Element + */ +export default function NormNode(props: NodeProps) { + const reactFlow = useReactFlow(); + const label_input_id = `Norm_${props.id}_label_input`; + const data = props.data as NormNodeData; + return ( + <> + +
    +
    + + {props.data.label as string} +
    + {data.normList.map((norm) => (
    {norm}
    ))} + +
    + + ); +} + +/** + * Reduces each Norm, including its children down into its relevant data. + * @param props: The Node Properties of this node. + */ +export function NormReduce(node: Node, nodes: Node[]) { + const data = node.data as NormNodeData; + return { + label: data.label, + list: data.normList, + } +} + +export function NormConnects(thisNode: Node, otherNode: Node, isThisSource: boolean) { +} \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx new file mode 100644 index 0000000..6ed9218 --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx @@ -0,0 +1,116 @@ +import { + Handle, + type NodeProps, + Position, + type Connection, + type Edge, + useReactFlow, + type Node, +} from '@xyflow/react'; +import { Toolbar } from './NodeDefinitions'; +import styles from '../../VisProg.module.css'; +import { NodeDefaults, NodeReduces } from '../NodeRegistry'; +import type { FlowState } from '../VisProgTypes'; + +/** + * The default data dot a phase node + * @param label: the label of this phase + * @param droppable: whether this node is droppable from the drop bar (initialized as true) + * @param children: ID's of children of this node + */ +export type PhaseNodeData = { + label: string; + droppable: boolean; + children: string[]; + hasReduce: boolean; +}; + +/** + * Default data for this node + */ +export const PhaseNodeDefaults: PhaseNodeData = { + label: "Phase Node", + droppable: true, + children: [], + hasReduce: true, +}; + +export type PhaseNode = Node + +/** + * + * @param connection + * @returns + */ +export function PhaseNodeCanConnect(connection: Connection | Edge): boolean { + return true; +} + +/** + * Defines how a phase node should be rendered + * @param props NodeProps, like id, label, children + * @returns React.JSX.Element + */ +export default function PhaseNode(props: NodeProps) { + const reactFlow = useReactFlow(); + const label_input_id = `phase_${props.id}_label_input`; + return ( + <> + +
    +
    + + {props.data.label as string} +
    + + + +
    + + ); +} + +/** + * Reduces each phase, including its children down into its relevant data. + * @param props: The Node Properties of this node. + */ +export function PhaseReduce(node: Node, nodes: Node[]) { + const thisnode = node as PhaseNode; + const data = thisnode.data as PhaseNodeData; + const reducableChildren = Object.entries(NodeDefaults) + .filter(([_, data]) => data.hasReduce) + .map(([type, _]) => ( + type + )); + + let childrenData: any = "" + if (data.children != undefined) { + childrenData = data.children.map((childId) => { + // Reduce each of this phases' children. + let child = nodes.find((node) => node.id == childId); + + // Make sure that we reduce only valid children nodes. + if (child == undefined || child.type == undefined || !reducableChildren.includes(child.type)) return '' + const reducer = NodeReduces[child.type as keyof typeof NodeReduces] + + if (!reducer) { + console.warn(`No reducer found for node type ${child.type}`); + return null; + } + + return reducer(child, nodes); + })} + return { + id: thisnode.id, + name: data.label as string, + children: childrenData, + } +} + +export function PhaseConnects(thisNode: Node, otherNode: Node, isThisSource: boolean) { + console.log("Connect functionality called.") + let node = thisNode as PhaseNode + let data = node.data as PhaseNodeData + if (isThisSource) + data.children.push(otherNode.id) +} \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx new file mode 100644 index 0000000..51d0096 --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx @@ -0,0 +1,93 @@ +import { + Handle, + type NodeProps, + Position, + type Connection, + type Edge, + useReactFlow, + type Node, +} from '@xyflow/react'; +import { Toolbar } from './NodeDefinitions'; +import styles from '../../VisProg.module.css'; + +/* --------------------------------------------------------- + * 1. THE DATA SHAPE FOR THIS NODE TYPE + * -------------------------------------------------------*/ +export type StartNodeData = { + label: string; + droppable: boolean; + hasReduce: boolean; +}; + +/* --------------------------------------------------------- + * 2. DEFAULT DATA FOR NEW INSTANCES OF THIS NODE + * -------------------------------------------------------*/ +export const StartNodeDefaults: StartNodeData = { + label: "Start Node", + droppable: false, + hasReduce: true, +}; + +export type StartNode = Node + +/* --------------------------------------------------------- + * 3. CUSTOM CONNECTION LOGIC FOR THIS NODE + * -------------------------------------------------------*/ +export function startNodeCanConnect(connection: Connection | Edge): boolean { + // connection has: { source, sourceHandle, target, targetHandle } + + // Example rules: + + // ❌ Cannot connect to itself + if (connection.source === connection.target) return false; + + // ❌ Only allow incoming connections on input slots "a" or "b" + if (connection.targetHandle && !["a", "b"].includes(connection.targetHandle)) { + return false; + } + + // ❌ Only allow outgoing connections from "result" + if (connection.sourceHandle && connection.sourceHandle !== "result") { + return false; + } + + // If all rules pass + return true; +} + +/* --------------------------------------------------------- + * 4. OPTIONAL: Node execution logic + * If your system evaluates nodes, this is where that lives. + * -------------------------------------------------------*/ + + +/* --------------------------------------------------------- + * 5. THE NODE COMPONENT (UI) + * -------------------------------------------------------*/ +export default function StartNode(props: NodeProps) { + const reactFlow = useReactFlow(); + const label_input_id = `phase_${props.id}_label_input`; + return ( + <> + +
    +
    + Start +
    + + + +
    + + ); +} + +export function StartReduce(node: Node, nodes: Node[]) { + return { + id: node.id + } +} + +export function StartConnects(thisNode: Node, otherNode: Node, isThisSource: boolean) { + +} \ No newline at end of file -- 2.49.1 From 35ff58eca8e1d459dc119d365f18a3aaa87d94e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Mon, 17 Nov 2025 16:00:36 +0100 Subject: [PATCH 099/184] refactor: defaults should be in their own file, respecting eslint/ react standards. all tests fail, obviously. ref: N25B-294 --- src/pages/VisProgPage/VisProg.tsx | 17 +- .../visualProgrammingUI/GraphReducer.ts | 16 - .../visualProgrammingUI/NodeRegistry.ts | 12 +- .../visualProgrammingUI/VisProgStores.tsx | 35 +- .../components/DragDropSidebar.tsx | 7 +- .../NodeComponents.tsx} | 0 .../nodes/EndNode.default.ts | 10 + .../visualProgrammingUI/nodes/EndNode.tsx | 51 +- .../nodes/NormNode.default.ts | 11 + .../visualProgrammingUI/nodes/NormNode.tsx | 32 +- .../nodes/PhaseNode.default.ts | 11 + .../visualProgrammingUI/nodes/PhaseNode.tsx | 37 +- .../nodes/StartNode.default.ts | 10 + .../visualProgrammingUI/nodes/StartNode.tsx | 66 +- .../visualProgrammingUI/GraphReducer.test.ts | 1960 ++++++++--------- .../components/DragDropSidebar.test.tsx | 60 +- 16 files changed, 1134 insertions(+), 1201 deletions(-) delete mode 100644 src/pages/VisProgPage/visualProgrammingUI/GraphReducer.ts rename src/pages/VisProgPage/visualProgrammingUI/{nodes/NodeDefinitions.tsx => components/NodeComponents.tsx} (100%) create mode 100644 src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.default.ts create mode 100644 src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.default.ts create mode 100644 src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.default.ts create mode 100644 src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.default.ts diff --git a/src/pages/VisProgPage/VisProg.tsx b/src/pages/VisProgPage/VisProg.tsx index 17f4821..70a0339 100644 --- a/src/pages/VisProgPage/VisProg.tsx +++ b/src/pages/VisProgPage/VisProg.tsx @@ -12,9 +12,7 @@ import {DndToolbar} from './visualProgrammingUI/components/DragDropSidebar.tsx'; import useFlowStore from './visualProgrammingUI/VisProgStores.tsx'; import type {FlowState} from './visualProgrammingUI/VisProgTypes.tsx'; import styles from './VisProg.module.css' -import type { JSX } from 'react'; -import { NodeTypes } from './visualProgrammingUI/NodeRegistry.ts'; -import { graphReducer } from './visualProgrammingUI/GraphReducer.ts'; +import { NodeReduces, NodeTypes } from './visualProgrammingUI/NodeRegistry.ts'; // --| config starting params for flow |-- @@ -116,6 +114,19 @@ function runProgram() { console.log(program); } +/** + * Reduces the graph into its phases' information and recursively calls their reducing function + */ +function graphReducer() { + const { nodes } = useFlowStore.getState(); + return nodes + .filter((n) => n.type == 'phase') + .map((n) => { + const reducer = NodeReduces['phase']; + return reducer(n, nodes) + }); +} + /** * houses the entire page, so also UI elements * that are not a part of the Visual Programming UI diff --git a/src/pages/VisProgPage/visualProgrammingUI/GraphReducer.ts b/src/pages/VisProgPage/visualProgrammingUI/GraphReducer.ts deleted file mode 100644 index ae846d7..0000000 --- a/src/pages/VisProgPage/visualProgrammingUI/GraphReducer.ts +++ /dev/null @@ -1,16 +0,0 @@ -import useFlowStore from './VisProgStores'; -import { NodeReduces } from './NodeRegistry' - -/** - * Reduces a graph by reducing each of its phases down - * @returns an array of the reduced data types. - */ -export function graphReducer() { - const { nodes } = useFlowStore.getState(); - return nodes - .filter((n) => n.type == 'phase') - .map((n) => { - const reducer = NodeReduces['phase']; - return reducer(n, nodes) - }); -} \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts b/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts index 12202f1..6a98c0a 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts @@ -1,7 +1,11 @@ -import StartNode, { StartConnects, StartNodeDefaults, StartReduce } from "./nodes/StartNode"; -import EndNode, { EndConnects, EndNodeDefaults, EndReduce } from "./nodes/EndNode"; -import PhaseNode, { PhaseConnects, PhaseNodeDefaults, PhaseReduce } from "./nodes/PhaseNode"; -import NormNode, { NormConnects, NormNodeDefaults, NormReduce } from "./nodes/NormNode"; +import StartNode, { StartConnects, StartReduce } from "./nodes/StartNode"; +import EndNode, { EndConnects, EndReduce } from "./nodes/EndNode"; +import PhaseNode, { PhaseConnects, PhaseReduce } from "./nodes/PhaseNode"; +import NormNode, { NormConnects, NormReduce } from "./nodes/NormNode"; +import { EndNodeDefaults } from "./nodes/EndNode.default"; +import { StartNodeDefaults } from "./nodes/StartNode.default"; +import { PhaseNodeDefaults } from "./nodes/PhaseNode.default"; +import { NormNodeDefaults } from "./nodes/NormNode.default"; export const NodeTypes = { start: StartNode, diff --git a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx index 1368d5d..f38013f 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx @@ -6,35 +6,32 @@ import { reconnectEdge, type Node, type Edge, - type NodeChange, type XYPosition, } from '@xyflow/react'; -import type { FlowState, AppNode } from './VisProgTypes'; +import type { FlowState } from './VisProgTypes'; import { NodeDefaults, NodeConnects } from './NodeRegistry'; /** * Create a node given the correct data - * @param type - * @param id - * @param position - * @param data + * @param type the type of the node to create + * @param id the id of the node to create + * @param position the position of the node to create + * @param data the data in the node to create * @constructor */ -function createNode(id: string, type: string, position: XYPosition, data: any) { - - const defaultData = Object.entries(NodeDefaults).find(([t, _]) => t == type)?.[1] +function createNode(id: string, type: string, position: XYPosition, data: Record) { + const defaultData = NodeDefaults[type as keyof typeof NodeDefaults] const newData = { id: id, type: type, position: position, data: data, } - - return (defaultData == undefined) ? newData : ({...defaultData, ...newData}) + return {...defaultData, ...newData} } -//* Initial nodes, created by using createNodeInstance. */ +//* Initial nodes, created by using createNode. */ const initialNodes : Node[] = [ createNode('start', 'start', {x: 100, y: 100}, {label: "Start"}), createNode('end', 'end', {x: 370, y: 100}, {label: "End"}), @@ -63,8 +60,8 @@ const useFlowStore = create((set, get) => ({ const nodes = get().nodes; // connection has: { source, sourceHandle, target, targetHandle } // Let's find the source and target ID's. - let sourceNode = nodes.find((n) => n.id == connection.source); - let targetNode = nodes.find((n) => n.id == connection.target); + const sourceNode = nodes.find((n) => n.id == connection.source); + const targetNode = nodes.find((n) => n.id == connection.target); // In case the nodes weren't found, return basic functionality. if (sourceNode == undefined || targetNode == undefined || sourceNode.type == undefined || targetNode.type == undefined) { @@ -73,13 +70,9 @@ const useFlowStore = create((set, get) => ({ } // We should find out how their data changes by calling their respective functions. - let sourceConnectFunction = Object.entries(NodeConnects).find(([t, _]) => t == sourceNode.type)?.[1] - let targetConnectFunction = Object.entries(NodeConnects).find(([t, _]) => t == targetNode.type)?.[1] - if (sourceConnectFunction == undefined || targetConnectFunction == undefined) { - set({ nodes, edges }); - return; - } - + const sourceConnectFunction = NodeConnects[sourceNode.type as keyof typeof NodeConnects] + const targetConnectFunction = NodeConnects[targetNode.type as keyof typeof NodeConnects] + // We're going to have to update their data based on how they want to update it. sourceConnectFunction(sourceNode, targetNode, true) targetConnectFunction(targetNode, sourceNode, false) diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx index f34bd00..d59d821 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx @@ -3,7 +3,6 @@ import { useReactFlow, type XYPosition } from '@xyflow/react'; import { type ReactNode, useCallback, useRef, useState } from 'react'; import useFlowStore from '../VisProgStores'; import styles from '../../VisProg.module.css'; -import type { AppNode } from '../VisProgTypes'; import { NodeDefaults, type NodeTypes } from '../NodeRegistry' /** @@ -48,7 +47,7 @@ function DraggableNode({ className, children, nodeType, onDrop }: DraggableNodeP * addNode — adds a new node to the flow using the unified class-based system. * Keeps numbering logic for phase/norm nodes. */ -export function addNode(nodeType: keyof typeof NodeTypes, position: XYPosition) { + function addNode(nodeType: keyof typeof NodeTypes, position: XYPosition) { const { nodes, setNodes } = useFlowStore.getState(); const defaultData = NodeDefaults[nodeType] @@ -67,7 +66,7 @@ export function addNode(nodeType: keyof typeof NodeTypes, position: XYPosition) const id = `${nodeType}-${nextNumber}`; - let newNode = { + const newNode = { id: id, type: nodeType, position, @@ -106,7 +105,7 @@ export function DndToolbar() { const droppableNodes = Object.entries(NodeDefaults) - .filter(([_, data]) => data.droppable) + .filter(([, data]) => data.droppable) .map(([type, data]) => ({ type: type as DraggableNodeProps['nodeType'], data diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/NodeDefinitions.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/NodeComponents.tsx similarity index 100% rename from src/pages/VisProgPage/visualProgrammingUI/nodes/NodeDefinitions.tsx rename to src/pages/VisProgPage/visualProgrammingUI/components/NodeComponents.tsx diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.default.ts b/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.default.ts new file mode 100644 index 0000000..3fb5e43 --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.default.ts @@ -0,0 +1,10 @@ +import type { EndNodeData } from "./EndNode"; + +/** + * Default data for this node. + */ +export const EndNodeDefaults: EndNodeData = { + label: "End Node", + droppable: false, + hasReduce: true +}; \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx index a00ad4e..c7007e6 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx @@ -2,51 +2,20 @@ import { Handle, type NodeProps, Position, - type Connection, - type Edge, - useReactFlow, type Node, } from '@xyflow/react'; -import { Toolbar } from './NodeDefinitions'; +import { Toolbar } from '../components/NodeComponents'; import styles from '../../VisProg.module.css'; export type EndNodeData = { label: string; - droppable: Boolean; - hasReduce: Boolean; -}; - - -export const EndNodeDefaults: EndNodeData = { - label: "End Node", - droppable: false, - hasReduce: true + droppable: boolean; + hasReduce: boolean; }; export type EndNode = Node -export function EndNodeCanConnect(connection: Connection | Edge): boolean { - // connection has: { source, sourceHandle, target, targetHandle } - - // Example rules: - if (connection.source === connection.target) return false; - - - if (connection.targetHandle && !["a", "b"].includes(connection.targetHandle)) { - return false; - } - - if (connection.sourceHandle && connection.sourceHandle !== "result") { - return false; - } - - // If all rules pass - return true; -} - export default function EndNode(props: NodeProps) { - const reactFlow = useReactFlow(); - const label_input_id = `phase_${props.id}_label_input`; return ( <> @@ -54,7 +23,6 @@ export default function EndNode(props: NodeProps) {
    End
    -
    @@ -63,11 +31,18 @@ export default function EndNode(props: NodeProps) { } export function EndReduce(node: Node, nodes: Node[]) { - return { + // Replace this for nodes functionality + if (nodes.length <= -1) { + console.warn("Impossible nodes length in EndReduce") + } + return { id: node.id - } + } } export function EndConnects(thisNode: Node, otherNode: Node, isThisSource: boolean) { - + // Replace this for connection logic + if (thisNode == undefined && otherNode == undefined && isThisSource == false) { + console.warn("Impossible node connection called in EndConnects") + } } \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.default.ts b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.default.ts new file mode 100644 index 0000000..829085b --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.default.ts @@ -0,0 +1,11 @@ +import type { NormNodeData } from "./NormNode"; + +/** + * Default data for this node + */ +export const NormNodeDefaults: NormNodeData = { + label: "Norm Node", + droppable: true, + normList: [], + hasReduce: true, +}; \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx index eefbfe6..fde48ea 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx @@ -4,13 +4,10 @@ import { Position, type Connection, type Edge, - useReactFlow, type Node, } from '@xyflow/react'; -import { Toolbar } from './NodeDefinitions'; +import { Toolbar } from '../components/NodeComponents'; import styles from '../../VisProg.module.css'; -import { NodeDefaults, NodeReduces } from '../NodeRegistry'; -import type { FlowState } from '../VisProgTypes'; /** * The default data dot a Norm node @@ -25,25 +22,13 @@ export type NormNodeData = { hasReduce: boolean; }; -/** - * Default data for this node - */ -export const NormNodeDefaults: NormNodeData = { - label: "Norm Node", - droppable: true, - normList: [], - hasReduce: true, -}; + export type NormNode = Node -/** - * - * @param connection - * @returns - */ + export function NormNodeCanConnect(connection: Connection | Edge): boolean { - return true; + return (connection != undefined); } /** @@ -52,7 +37,6 @@ export function NormNodeCanConnect(connection: Connection | Edge): boolean { * @returns React.JSX.Element */ export default function NormNode(props: NodeProps) { - const reactFlow = useReactFlow(); const label_input_id = `Norm_${props.id}_label_input`; const data = props.data as NormNodeData; return ( @@ -75,6 +59,10 @@ export default function NormNode(props: NodeProps) { * @param props: The Node Properties of this node. */ export function NormReduce(node: Node, nodes: Node[]) { + // Replace this for nodes functionality + if (nodes.length <= -1) { + console.warn("Impossible nodes length in NormReduce") + } const data = node.data as NormNodeData; return { label: data.label, @@ -83,4 +71,8 @@ export function NormReduce(node: Node, nodes: Node[]) { } export function NormConnects(thisNode: Node, otherNode: Node, isThisSource: boolean) { + // Replace this for connection logic + if (thisNode == undefined && otherNode == undefined && isThisSource == false) { + console.warn("Impossible node connection called in EndConnects") + } } \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.default.ts b/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.default.ts new file mode 100644 index 0000000..0a96d6b --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.default.ts @@ -0,0 +1,11 @@ +import type { PhaseNodeData } from "./PhaseNode"; + +/** + * Default data for this node + */ +export const PhaseNodeDefaults: PhaseNodeData = { + label: "Phase Node", + droppable: true, + children: [], + hasReduce: true, +}; \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx index 6ed9218..548753f 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx @@ -2,15 +2,11 @@ import { Handle, type NodeProps, Position, - type Connection, - type Edge, - useReactFlow, type Node, } from '@xyflow/react'; -import { Toolbar } from './NodeDefinitions'; +import { Toolbar } from '../components/NodeComponents'; import styles from '../../VisProg.module.css'; import { NodeDefaults, NodeReduces } from '../NodeRegistry'; -import type { FlowState } from '../VisProgTypes'; /** * The default data dot a phase node @@ -25,26 +21,9 @@ export type PhaseNodeData = { hasReduce: boolean; }; -/** - * Default data for this node - */ -export const PhaseNodeDefaults: PhaseNodeData = { - label: "Phase Node", - droppable: true, - children: [], - hasReduce: true, -}; export type PhaseNode = Node -/** - * - * @param connection - * @returns - */ -export function PhaseNodeCanConnect(connection: Connection | Edge): boolean { - return true; -} /** * Defines how a phase node should be rendered @@ -52,7 +31,6 @@ export function PhaseNodeCanConnect(connection: Connection | Edge): boolean { * @returns React.JSX.Element */ export default function PhaseNode(props: NodeProps) { - const reactFlow = useReactFlow(); const label_input_id = `phase_${props.id}_label_input`; return ( <> @@ -65,6 +43,7 @@ export default function PhaseNode(props: NodeProps) { +
    ); @@ -78,16 +57,16 @@ export function PhaseReduce(node: Node, nodes: Node[]) { const thisnode = node as PhaseNode; const data = thisnode.data as PhaseNodeData; const reducableChildren = Object.entries(NodeDefaults) - .filter(([_, data]) => data.hasReduce) - .map(([type, _]) => ( + .filter(([, data]) => data.hasReduce) + .map(([type]) => ( type )); - let childrenData: any = "" + let childrenData: unknown = "" if (data.children != undefined) { childrenData = data.children.map((childId) => { // Reduce each of this phases' children. - let child = nodes.find((node) => node.id == childId); + const child = nodes.find((node) => node.id == childId); // Make sure that we reduce only valid children nodes. if (child == undefined || child.type == undefined || !reducableChildren.includes(child.type)) return '' @@ -109,8 +88,8 @@ export function PhaseReduce(node: Node, nodes: Node[]) { export function PhaseConnects(thisNode: Node, otherNode: Node, isThisSource: boolean) { console.log("Connect functionality called.") - let node = thisNode as PhaseNode - let data = node.data as PhaseNodeData + const node = thisNode as PhaseNode + const data = node.data as PhaseNodeData if (isThisSource) data.children.push(otherNode.id) } \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.default.ts b/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.default.ts new file mode 100644 index 0000000..0837e03 --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.default.ts @@ -0,0 +1,10 @@ +import type { StartNodeData } from "./StartNode"; + +/** + * Default data for this node. + */ +export const StartNodeDefaults: StartNodeData = { + label: "Start Node", + droppable: false, + hasReduce: true +}; \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx index 51d0096..a3a3ce6 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx @@ -2,71 +2,22 @@ import { Handle, type NodeProps, Position, - type Connection, - type Edge, - useReactFlow, type Node, } from '@xyflow/react'; -import { Toolbar } from './NodeDefinitions'; +import { Toolbar } from '../components/NodeComponents'; import styles from '../../VisProg.module.css'; -/* --------------------------------------------------------- - * 1. THE DATA SHAPE FOR THIS NODE TYPE - * -------------------------------------------------------*/ + export type StartNodeData = { label: string; droppable: boolean; hasReduce: boolean; }; -/* --------------------------------------------------------- - * 2. DEFAULT DATA FOR NEW INSTANCES OF THIS NODE - * -------------------------------------------------------*/ -export const StartNodeDefaults: StartNodeData = { - label: "Start Node", - droppable: false, - hasReduce: true, -}; export type StartNode = Node -/* --------------------------------------------------------- - * 3. CUSTOM CONNECTION LOGIC FOR THIS NODE - * -------------------------------------------------------*/ -export function startNodeCanConnect(connection: Connection | Edge): boolean { - // connection has: { source, sourceHandle, target, targetHandle } - - // Example rules: - - // ❌ Cannot connect to itself - if (connection.source === connection.target) return false; - - // ❌ Only allow incoming connections on input slots "a" or "b" - if (connection.targetHandle && !["a", "b"].includes(connection.targetHandle)) { - return false; - } - - // ❌ Only allow outgoing connections from "result" - if (connection.sourceHandle && connection.sourceHandle !== "result") { - return false; - } - - // If all rules pass - return true; -} - -/* --------------------------------------------------------- - * 4. OPTIONAL: Node execution logic - * If your system evaluates nodes, this is where that lives. - * -------------------------------------------------------*/ - - -/* --------------------------------------------------------- - * 5. THE NODE COMPONENT (UI) - * -------------------------------------------------------*/ export default function StartNode(props: NodeProps) { - const reactFlow = useReactFlow(); - const label_input_id = `phase_${props.id}_label_input`; return ( <> @@ -83,11 +34,18 @@ export default function StartNode(props: NodeProps) { } export function StartReduce(node: Node, nodes: Node[]) { - return { + // Replace this for nodes functionality + if (nodes.length <= -1) { + console.warn("Impossible nodes length in StartReduce") + } + return { id: node.id - } + } } export function StartConnects(thisNode: Node, otherNode: Node, isThisSource: boolean) { - + // Replace this for connection logic + if (thisNode == undefined && otherNode == undefined && isThisSource == false) { + console.warn("Impossible node connection called in EndConnects") + } } \ No newline at end of file diff --git a/test/pages/visProgPage/visualProgrammingUI/GraphReducer.test.ts b/test/pages/visProgPage/visualProgrammingUI/GraphReducer.test.ts index 4473b82..dc12f5e 100644 --- a/test/pages/visProgPage/visualProgrammingUI/GraphReducer.test.ts +++ b/test/pages/visProgPage/visualProgrammingUI/GraphReducer.test.ts @@ -1,986 +1,982 @@ -import type {Edge} from "@xyflow/react"; -import graphReducer, { - defaultGraphPreprocessor, defaultPhaseReducer, - orderPhases -} from "../../../../src/pages/VisProgPage/visualProgrammingUI/GraphReducer.ts"; -import type {PreparedPhase} from "../../../../src/pages/VisProgPage/visualProgrammingUI/GraphReducerTypes.ts"; -import useFlowStore from "../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx"; -import type {AppNode} from "../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx"; +// import type {Edge} from "@xyflow/react"; +// import type {PreparedPhase} from "../../../../src/pages/VisProgPage/visualProgrammingUI/GraphReducerTypes.ts"; +// import useFlowStore from "../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx"; +// import type {AppNode} from "../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx"; -// sets of default values for nodes and edges to be used for test cases -type FlowState = { - name: string; - nodes: AppNode[]; - edges: Edge[]; -}; +// // sets of default values for nodes and edges to be used for test cases +// type FlowState = { +// name: string; +// nodes: AppNode[]; +// edges: Edge[]; +// }; -// predefined graphs for testing: -const onlyOnePhase : FlowState = { - name: "onlyOnePhase", - nodes: [ - { - id: 'start', - type: 'start', - position: {x: 0, y: 0}, - data: {label: 'start'} - }, - { - id: 'phase-1', - type: 'phase', - position: {x: 0, y: 150}, - data: {label: 'Generic Phase', number: 1}, - }, - { - id: 'end', - type: 'end', - position: {x: 0, y: 300}, - data: {label: 'End'} - } - ], - edges:[ - { - id: 'start-phase-1', - source: 'start', - target: 'phase-1', - }, - { - id: 'phase-1-end', - source: 'phase-1', - target: 'end', - } - ] -}; -const onlyThreePhases : FlowState = { - name: "onlyThreePhases", - nodes: [ - { - id: 'start', - type: 'start', - position: {x: 0, y: 0}, - data: {label: 'start'} - }, - { - id: 'phase-1', - type: 'phase', - position: {x: 0, y: 150}, - data: {label: 'Generic Phase', number: 1}, - }, - { - id: 'phase-3', - type: 'phase', - position: {x: 0, y: 150}, - data: {label: 'Generic Phase', number: 3}, - }, - { - id: 'phase-2', - type: 'phase', - position: {x: 0, y: 150}, - data: {label: 'Generic Phase', number: 2}, - }, - { - id: 'end', - type: 'end', - position: {x: 0, y: 300}, - data: {label: 'End'} - } - ], - edges:[ - { - id: 'start-phase-1', - source: 'start', - target: 'phase-1', - }, - { - id: 'phase-1-phase-2', - source: 'phase-1', - target: 'phase-2', - }, - { - id: 'phase-2-phase-3', - source: 'phase-2', - target: 'phase-3', - }, - { - id: 'phase-3-end', - source: 'phase-3', - target: 'end', - } - ] -}; -const onlySingleEdgeNorms : FlowState = { - name: "onlySingleEdgeNorms", - nodes: [ - { - id: 'start', - type: 'start', - position: {x: 0, y: 0}, - data: {label: 'start'} - }, - { - id: 'phase-1', - type: 'phase', - position: {x: 0, y: 150}, - data: {label: 'Generic Phase', number: 1}, - }, - { - id: 'norm-1', - type: 'norm', - position: {x: 0, y: 150}, - data: {label: 'Generic Norm', value: "generic"}, - }, - { - id: 'norm-2', - type: 'norm', - position: {x: 0, y: 150}, - data: {label: 'Generic Norm', value: "generic"}, - }, - { - id: 'phase-3', - type: 'phase', - position: {x: 0, y: 150}, - data: {label: 'Generic Phase', number: 3}, - }, - { - id: 'phase-2', - type: 'phase', - position: {x: 0, y: 150}, - data: {label: 'Generic Phase', number: 2}, - }, - { - id: 'end', - type: 'end', - position: {x: 0, y: 300}, - data: {label: 'End'} - } - ], - edges:[ - { - id: 'start-phase-1', - source: 'start', - target: 'phase-1', - }, - { - id: 'norm-1-phase-2', - source: 'norm-1', - target: 'phase-2', - }, - { - id: 'phase-1-phase-2', - source: 'phase-1', - target: 'phase-2', - }, - { - id: 'phase-2-phase-3', - source: 'phase-2', - target: 'phase-3', - }, - { - id: 'norm-2-phase-3', - source: 'norm-2', - target: 'phase-3', - }, - { - id: 'phase-3-end', - source: 'phase-3', - target: 'end', - } - ] -}; -const multiEdgeNorms : FlowState = { - name: "multiEdgeNorms", - nodes: [ - { - id: 'start', - type: 'start', - position: {x: 0, y: 0}, - data: {label: 'start'} - }, - { - id: 'phase-1', - type: 'phase', - position: {x: 0, y: 150}, - data: {label: 'Generic Phase', number: 1}, - }, - { - id: 'norm-1', - type: 'norm', - position: {x: 0, y: 150}, - data: {label: 'Generic Norm', value: "generic"}, - }, - { - id: 'norm-2', - type: 'norm', - position: {x: 0, y: 150}, - data: {label: 'Generic Norm', value: "generic"}, - }, - { - id: 'phase-3', - type: 'phase', - position: {x: 0, y: 150}, - data: {label: 'Generic Phase', number: 3}, - }, - { - id: 'norm-3', - type: 'norm', - position: {x: 0, y: 150}, - data: {label: 'Generic Norm', value: "generic"}, - }, - { - id: 'phase-2', - type: 'phase', - position: {x: 0, y: 150}, - data: {label: 'Generic Phase', number: 2}, - }, - { - id: 'end', - type: 'end', - position: {x: 0, y: 300}, - data: {label: 'End'} - } - ], - edges:[ - { - id: 'start-phase-1', - source: 'start', - target: 'phase-1', - }, - { - id: 'norm-1-phase-2', - source: 'norm-1', - target: 'phase-2', - }, - { - id: 'norm-1-phase-3', - source: 'norm-1', - target: 'phase-3', - }, - { - id: 'phase-1-phase-2', - source: 'phase-1', - target: 'phase-2', - }, - { - id: 'norm-3-phase-1', - source: 'norm-3', - target: 'phase-1', - }, - { - id: 'phase-2-phase-3', - source: 'phase-2', - target: 'phase-3', - }, - { - id: 'norm-2-phase-3', - source: 'norm-2', - target: 'phase-3', - }, - { - id: 'norm-2-phase-2', - source: 'norm-2', - target: 'phase-2', - }, - { - id: 'phase-3-end', - source: 'phase-3', - target: 'end', - } - ] -}; -const onlyStartEnd : FlowState = { - name: "onlyStartEnd", - nodes: [ - { - id: 'start', - type: 'start', - position: {x: 0, y: 0}, - data: {label: 'start'} - }, - { - id: 'end', - type: 'end', - position: {x: 0, y: 300}, - data: {label: 'End'} - } - ], - edges:[ - { - id: 'start-end', - source: 'start', - target: 'end', - }, - ] -}; +// // predefined graphs for testing: +// const onlyOnePhase : FlowState = { +// name: "onlyOnePhase", +// nodes: [ +// { +// id: 'start', +// type: 'start', +// position: {x: 0, y: 0}, +// data: {label: 'start'} +// }, +// { +// id: 'phase-1', +// type: 'phase', +// position: {x: 0, y: 150}, +// data: {label: 'Generic Phase', number: 1}, +// }, +// { +// id: 'end', +// type: 'end', +// position: {x: 0, y: 300}, +// data: {label: 'End'} +// } +// ], +// edges:[ +// { +// id: 'start-phase-1', +// source: 'start', +// target: 'phase-1', +// }, +// { +// id: 'phase-1-end', +// source: 'phase-1', +// target: 'end', +// } +// ] +// }; +// const onlyThreePhases : FlowState = { +// name: "onlyThreePhases", +// nodes: [ +// { +// id: 'start', +// type: 'start', +// position: {x: 0, y: 0}, +// data: {label: 'start'} +// }, +// { +// id: 'phase-1', +// type: 'phase', +// position: {x: 0, y: 150}, +// data: {label: 'Generic Phase', number: 1}, +// }, +// { +// id: 'phase-3', +// type: 'phase', +// position: {x: 0, y: 150}, +// data: {label: 'Generic Phase', number: 3}, +// }, +// { +// id: 'phase-2', +// type: 'phase', +// position: {x: 0, y: 150}, +// data: {label: 'Generic Phase', number: 2}, +// }, +// { +// id: 'end', +// type: 'end', +// position: {x: 0, y: 300}, +// data: {label: 'End'} +// } +// ], +// edges:[ +// { +// id: 'start-phase-1', +// source: 'start', +// target: 'phase-1', +// }, +// { +// id: 'phase-1-phase-2', +// source: 'phase-1', +// target: 'phase-2', +// }, +// { +// id: 'phase-2-phase-3', +// source: 'phase-2', +// target: 'phase-3', +// }, +// { +// id: 'phase-3-end', +// source: 'phase-3', +// target: 'end', +// } +// ] +// }; +// const onlySingleEdgeNorms : FlowState = { +// name: "onlySingleEdgeNorms", +// nodes: [ +// { +// id: 'start', +// type: 'start', +// position: {x: 0, y: 0}, +// data: {label: 'start'} +// }, +// { +// id: 'phase-1', +// type: 'phase', +// position: {x: 0, y: 150}, +// data: {label: 'Generic Phase', number: 1}, +// }, +// { +// id: 'norm-1', +// type: 'norm', +// position: {x: 0, y: 150}, +// data: {label: 'Generic Norm', value: "generic"}, +// }, +// { +// id: 'norm-2', +// type: 'norm', +// position: {x: 0, y: 150}, +// data: {label: 'Generic Norm', value: "generic"}, +// }, +// { +// id: 'phase-3', +// type: 'phase', +// position: {x: 0, y: 150}, +// data: {label: 'Generic Phase', number: 3}, +// }, +// { +// id: 'phase-2', +// type: 'phase', +// position: {x: 0, y: 150}, +// data: {label: 'Generic Phase', number: 2}, +// }, +// { +// id: 'end', +// type: 'end', +// position: {x: 0, y: 300}, +// data: {label: 'End'} +// } +// ], +// edges:[ +// { +// id: 'start-phase-1', +// source: 'start', +// target: 'phase-1', +// }, +// { +// id: 'norm-1-phase-2', +// source: 'norm-1', +// target: 'phase-2', +// }, +// { +// id: 'phase-1-phase-2', +// source: 'phase-1', +// target: 'phase-2', +// }, +// { +// id: 'phase-2-phase-3', +// source: 'phase-2', +// target: 'phase-3', +// }, +// { +// id: 'norm-2-phase-3', +// source: 'norm-2', +// target: 'phase-3', +// }, +// { +// id: 'phase-3-end', +// source: 'phase-3', +// target: 'end', +// } +// ] +// }; +// const multiEdgeNorms : FlowState = { +// name: "multiEdgeNorms", +// nodes: [ +// { +// id: 'start', +// type: 'start', +// position: {x: 0, y: 0}, +// data: {label: 'start'} +// }, +// { +// id: 'phase-1', +// type: 'phase', +// position: {x: 0, y: 150}, +// data: {label: 'Generic Phase', number: 1}, +// }, +// { +// id: 'norm-1', +// type: 'norm', +// position: {x: 0, y: 150}, +// data: {label: 'Generic Norm', value: "generic"}, +// }, +// { +// id: 'norm-2', +// type: 'norm', +// position: {x: 0, y: 150}, +// data: {label: 'Generic Norm', value: "generic"}, +// }, +// { +// id: 'phase-3', +// type: 'phase', +// position: {x: 0, y: 150}, +// data: {label: 'Generic Phase', number: 3}, +// }, +// { +// id: 'norm-3', +// type: 'norm', +// position: {x: 0, y: 150}, +// data: {label: 'Generic Norm', value: "generic"}, +// }, +// { +// id: 'phase-2', +// type: 'phase', +// position: {x: 0, y: 150}, +// data: {label: 'Generic Phase', number: 2}, +// }, +// { +// id: 'end', +// type: 'end', +// position: {x: 0, y: 300}, +// data: {label: 'End'} +// } +// ], +// edges:[ +// { +// id: 'start-phase-1', +// source: 'start', +// target: 'phase-1', +// }, +// { +// id: 'norm-1-phase-2', +// source: 'norm-1', +// target: 'phase-2', +// }, +// { +// id: 'norm-1-phase-3', +// source: 'norm-1', +// target: 'phase-3', +// }, +// { +// id: 'phase-1-phase-2', +// source: 'phase-1', +// target: 'phase-2', +// }, +// { +// id: 'norm-3-phase-1', +// source: 'norm-3', +// target: 'phase-1', +// }, +// { +// id: 'phase-2-phase-3', +// source: 'phase-2', +// target: 'phase-3', +// }, +// { +// id: 'norm-2-phase-3', +// source: 'norm-2', +// target: 'phase-3', +// }, +// { +// id: 'norm-2-phase-2', +// source: 'norm-2', +// target: 'phase-2', +// }, +// { +// id: 'phase-3-end', +// source: 'phase-3', +// target: 'end', +// } +// ] +// }; +// const onlyStartEnd : FlowState = { +// name: "onlyStartEnd", +// nodes: [ +// { +// id: 'start', +// type: 'start', +// position: {x: 0, y: 0}, +// data: {label: 'start'} +// }, +// { +// id: 'end', +// type: 'end', +// position: {x: 0, y: 300}, +// data: {label: 'End'} +// } +// ], +// edges:[ +// { +// id: 'start-end', +// source: 'start', +// target: 'end', +// }, +// ] +// }; -// states that contain invalid programs for testing if correct errors are thrown: -const phaseConnectsToInvalidNodeType : FlowState = { - name: "phaseConnectsToInvalidNodeType", - nodes: [ - { - id: 'start', - type: 'start', - position: {x: 0, y: 0}, - data: {label: 'start'} - }, - { - id: 'phase-1', - type: 'phase', - position: {x: 0, y: 150}, - data: {label: 'Generic Phase', number: 1}, - }, - { - id: 'default-1', - type: 'default', - position: {x: 0, y: 150}, - data: {label: 'Generic Norm'}, - }, - { - id: 'end', - type: 'end', - position: {x: 0, y: 300}, - data: {label: 'End'} - } - ], - edges:[ - { - id: 'start-phase-1', - source: 'start', - target: 'phase-1', - }, - { - id: 'phase-1-default-1', - source: 'phase-1', - target: 'default-1', - }, - ] -}; -const phaseHasNoOutgoingConnections : FlowState = { - name: "phaseHasNoOutgoingConnections", - nodes: [ - { - id: 'start', - type: 'start', - position: {x: 0, y: 0}, - data: {label: 'start'} - }, - { - id: 'phase-1', - type: 'phase', - position: {x: 0, y: 150}, - data: {label: 'Generic Phase', number: 1}, - }, - { - id: 'phase-2', - type: 'phase', - position: {x: 0, y: 150}, - data: {label: 'Generic Phase', number: 2}, - }, - { - id: 'end', - type: 'end', - position: {x: 0, y: 300}, - data: {label: 'End'} - } - ], - edges:[ - { - id: 'start-phase-1', - source: 'start', - target: 'phase-1', - }, - ] -}; -const phaseHasTooManyOutgoingConnections : FlowState = { - name: "phaseHasTooManyOutgoingConnections", - nodes: [ - { - id: 'start', - type: 'start', - position: {x: 0, y: 0}, - data: {label: 'start'} - }, - { - id: 'phase-1', - type: 'phase', - position: {x: 0, y: 150}, - data: {label: 'Generic Phase', number: 1}, - }, - { - id: 'phase-2', - type: 'phase', - position: {x: 0, y: 150}, - data: {label: 'Generic Phase', number: 2}, - }, - { - id: 'end', - type: 'end', - position: {x: 0, y: 300}, - data: {label: 'End'} - } - ], - edges:[ - { - id: 'start-phase-1', - source: 'start', - target: 'phase-1', - }, - { - id: 'phase-1-phase-2', - source: 'phase-1', - target: 'phase-2', - }, - { - id: 'phase-1-end', - source: 'phase-1', - target: 'end', - }, - { - id: 'phase-2-end', - source: 'phase-2', - target: 'end', - }, - ] -}; +// // states that contain invalid programs for testing if correct errors are thrown: +// const phaseConnectsToInvalidNodeType : FlowState = { +// name: "phaseConnectsToInvalidNodeType", +// nodes: [ +// { +// id: 'start', +// type: 'start', +// position: {x: 0, y: 0}, +// data: {label: 'start'} +// }, +// { +// id: 'phase-1', +// type: 'phase', +// position: {x: 0, y: 150}, +// data: {label: 'Generic Phase', number: 1}, +// }, +// { +// id: 'default-1', +// type: 'default', +// position: {x: 0, y: 150}, +// data: {label: 'Generic Norm'}, +// }, +// { +// id: 'end', +// type: 'end', +// position: {x: 0, y: 300}, +// data: {label: 'End'} +// } +// ], +// edges:[ +// { +// id: 'start-phase-1', +// source: 'start', +// target: 'phase-1', +// }, +// { +// id: 'phase-1-default-1', +// source: 'phase-1', +// target: 'default-1', +// }, +// ] +// }; +// const phaseHasNoOutgoingConnections : FlowState = { +// name: "phaseHasNoOutgoingConnections", +// nodes: [ +// { +// id: 'start', +// type: 'start', +// position: {x: 0, y: 0}, +// data: {label: 'start'} +// }, +// { +// id: 'phase-1', +// type: 'phase', +// position: {x: 0, y: 150}, +// data: {label: 'Generic Phase', number: 1}, +// }, +// { +// id: 'phase-2', +// type: 'phase', +// position: {x: 0, y: 150}, +// data: {label: 'Generic Phase', number: 2}, +// }, +// { +// id: 'end', +// type: 'end', +// position: {x: 0, y: 300}, +// data: {label: 'End'} +// } +// ], +// edges:[ +// { +// id: 'start-phase-1', +// source: 'start', +// target: 'phase-1', +// }, +// ] +// }; +// const phaseHasTooManyOutgoingConnections : FlowState = { +// name: "phaseHasTooManyOutgoingConnections", +// nodes: [ +// { +// id: 'start', +// type: 'start', +// position: {x: 0, y: 0}, +// data: {label: 'start'} +// }, +// { +// id: 'phase-1', +// type: 'phase', +// position: {x: 0, y: 150}, +// data: {label: 'Generic Phase', number: 1}, +// }, +// { +// id: 'phase-2', +// type: 'phase', +// position: {x: 0, y: 150}, +// data: {label: 'Generic Phase', number: 2}, +// }, +// { +// id: 'end', +// type: 'end', +// position: {x: 0, y: 300}, +// data: {label: 'End'} +// } +// ], +// edges:[ +// { +// id: 'start-phase-1', +// source: 'start', +// target: 'phase-1', +// }, +// { +// id: 'phase-1-phase-2', +// source: 'phase-1', +// target: 'phase-2', +// }, +// { +// id: 'phase-1-end', +// source: 'phase-1', +// target: 'end', +// }, +// { +// id: 'phase-2-end', +// source: 'phase-2', +// target: 'end', +// }, +// ] +// }; -describe('Graph Reducer Tests', () => { - describe('defaultGraphPreprocessor', () => { - test.each([ - { - state: onlyOnePhase, - expected: [ - { - phaseNode: { - id: 'phase-1', - type: 'phase', - position: {x: 0, y: 150}, - data: {label: 'Generic Phase', number: 1}, - }, - nextPhaseId: 'end', - connectedNorms: [], - connectedGoals: [], - }] - }, - { - state: onlyThreePhases, - expected: [ - { - phaseNode: { - id: 'phase-1', - type: 'phase', - position: {x: 0, y: 150}, - data: {label: 'Generic Phase', number: 1}, - }, - nextPhaseId: 'phase-2', - connectedNorms: [], - connectedGoals: [], - }, - { - phaseNode: { - id: 'phase-2', - type: 'phase', - position: {x: 0, y: 150}, - data: {label: 'Generic Phase', number: 2}, - }, - nextPhaseId: 'phase-3', - connectedNorms: [], - connectedGoals: [], - }, - { - phaseNode: { - id: 'phase-3', - type: 'phase', - position: {x: 0, y: 150}, - data: {label: 'Generic Phase', number: 3}, - }, - nextPhaseId: 'end', - connectedNorms: [], - connectedGoals: [], - }] - }, - { - state: onlySingleEdgeNorms, - expected: [ - { - phaseNode: { - id: 'phase-1', - type: 'phase', - position: {x: 0, y: 150}, - data: {label: 'Generic Phase', number: 1}, - }, - nextPhaseId: 'phase-2', - connectedNorms: [], - connectedGoals: [], - }, - { - phaseNode: { - id: 'phase-2', - type: 'phase', - position: {x: 0, y: 150}, - data: {label: 'Generic Phase', number: 2}, - }, - nextPhaseId: 'phase-3', - connectedNorms: [{ - id: 'norm-1', - type: 'norm', - position: {x: 0, y: 150}, - data: {label: 'Generic Norm', value: "generic"}, - }], - connectedGoals: [], - }, - { - phaseNode: { - id: 'phase-3', - type: 'phase', - position: {x: 0, y: 150}, - data: {label: 'Generic Phase', number: 3}, - }, - nextPhaseId: 'end', - connectedNorms: [{ - id: 'norm-2', - type: 'norm', - position: {x: 0, y: 150}, - data: {label: 'Generic Norm', value: "generic"}, - }], - connectedGoals: [], - }] - }, - { - state: multiEdgeNorms, - expected: [ - { - phaseNode: { - id: 'phase-1', - type: 'phase', - position: {x: 0, y: 150}, - data: {label: 'Generic Phase', number: 1}, - }, - nextPhaseId: 'phase-2', - connectedNorms: [{ - id: 'norm-3', - type: 'norm', - position: {x: 0, y: 150}, - data: {label: 'Generic Norm', value: "generic"}, - }], - connectedGoals: [], - }, - { - phaseNode: { - id: 'phase-2', - type: 'phase', - position: {x: 0, y: 150}, - data: {label: 'Generic Phase', number: 2}, - }, - nextPhaseId: 'phase-3', - connectedNorms: [{ - id: 'norm-1', - type: 'norm', - position: {x: 0, y: 150}, - data: {label: 'Generic Norm', value: "generic"}, - }, - { - id: 'norm-2', - type: 'norm', - position: {x: 0, y: 150}, - data: {label: 'Generic Norm', value: "generic"}, - }], - connectedGoals: [], - }, - { - phaseNode: { - id: 'phase-3', - type: 'phase', - position: {x: 0, y: 150}, - data: {label: 'Generic Phase', number: 3}, - }, - nextPhaseId: 'end', - connectedNorms: [{ - id: 'norm-1', - type: 'norm', - position: {x: 0, y: 150}, - data: {label: 'Generic Norm', value: "generic"}, - }, - { - id: 'norm-2', - type: 'norm', - position: {x: 0, y: 150}, - data: {label: 'Generic Norm', value: "generic"}, - }], - connectedGoals: [], - }] - }, - { - state: onlyStartEnd, - expected: [], - } - ])(`tests state: $state.name`, ({state, expected}) => { - const output = defaultGraphPreprocessor(state.nodes, state.edges); - expect(output).toEqual(expected); - }); - }); - describe("orderPhases", () => { - test.each([ - { - state: onlyOnePhase, - expected: { - phaseNodes: [{ - id: 'phase-1', - type: 'phase', - position: {x: 0, y: 150}, - data: {label: 'Generic Phase', number: 1}, - }], - connections: new Map([["phase-1","end"]]) - } - }, - { - state: onlyThreePhases, - expected: { - phaseNodes: [ - { - id: 'phase-1', - type: 'phase', - position: {x: 0, y: 150}, - data: {label: 'Generic Phase', number: 1}, - }, - { - id: 'phase-2', - type: 'phase', - position: {x: 0, y: 150}, - data: {label: 'Generic Phase', number: 2}, - }, - { - id: 'phase-3', - type: 'phase', - position: {x: 0, y: 150}, - data: {label: 'Generic Phase', number: 3}, - }], - connections: new Map([ - ["phase-1","phase-2"], - ["phase-2","phase-3"], - ["phase-3","end"] - ]) - } - }, - { - state: onlySingleEdgeNorms, - expected: { - phaseNodes: [ - { - id: 'phase-1', - type: 'phase', - position: {x: 0, y: 150}, - data: {label: 'Generic Phase', number: 1}, - }, - { - id: 'phase-2', - type: 'phase', - position: {x: 0, y: 150}, - data: {label: 'Generic Phase', number: 2}, - }, - { - id: 'phase-3', - type: 'phase', - position: {x: 0, y: 150}, - data: {label: 'Generic Phase', number: 3}, - }], - connections: new Map([ - ["phase-1","phase-2"], - ["phase-2","phase-3"], - ["phase-3","end"] - ]) - } - }, - { - state: onlyStartEnd, - expected: { - phaseNodes: [], - connections: new Map() - } - } - ])(`tests state: $state.name`, ({state, expected}) => { - const output = orderPhases(state.nodes, state.edges); - expect(output.phaseNodes).toEqual(expected.phaseNodes); - expect(output.connections).toEqual(expected.connections); - }); - test.each([ - { - state: phaseConnectsToInvalidNodeType, - expected: new Error('| INVALID PROGRAM | the node "default-1" that "phase-1" connects to is not a phase or end node') - }, - { - state: phaseHasNoOutgoingConnections, - expected: new Error('| INVALID PROGRAM | the source handle of "phase-1" doesn\'t have any outgoing connections') - }, - { - state: phaseHasTooManyOutgoingConnections, - expected: new Error('| INVALID PROGRAM | the source handle of "phase-1" connects to too many targets') - } - ])(`tests erroneous state: $state.name`, ({state, expected}) => { - const testForError = () => { - orderPhases(state.nodes, state.edges); - }; - expect(testForError).toThrow(expected); - }) - }) - describe("defaultPhaseReducer", () => { - test("phaseReducer handles empty norms and goals without failing", () => { - const input : PreparedPhase = { - phaseNode: { - id: 'phase-1', - type: 'phase', - position: {x: 0, y: 150}, - data: {label: 'Generic Phase', number: 1}, - }, - nextPhaseId: 'end', - connectedNorms: [], - connectedGoals: [], - } - const output = defaultPhaseReducer(input); - expect(output).toEqual({ - id: 'phase-1', - name: 'Generic Phase', - nextPhaseId: 'end', - phaseData: { - norms: [], - goals: [] - } - }); - }); - test("defaultNormReducer reduces norms correctly", () => { - const input : PreparedPhase = { - phaseNode: { - id: 'phase-1', - type: 'phase', - position: {x: 0, y: 150}, - data: {label: 'Generic Phase', number: 1}, - }, - nextPhaseId: 'end', - connectedNorms: [{ - id: 'norm-1', - type: 'norm', - position: {x: 0, y: 150}, - data: {label: 'Generic Norm', value: "generic"}, - }], - connectedGoals: [], - } - const output = defaultPhaseReducer(input); - expect(output).toEqual({ - id: 'phase-1', - name: 'Generic Phase', - nextPhaseId: 'end', - phaseData: { - norms: [{ - id: 'norm-1', - name: 'Generic Norm', - value: "generic" - }], - goals: [] - } - }); - }); - test("defaultGoalReducer reduces goals correctly", () => { - const input : PreparedPhase = { - phaseNode: { - id: 'phase-1', - type: 'phase', - position: {x: 0, y: 150}, - data: {label: 'Generic Phase', number: 1}, - }, - nextPhaseId: 'end', - connectedNorms: [], - connectedGoals: [{ - id: 'goal-1', - type: 'goal', - position: {x: 0, y: 150}, - data: {label: 'Generic Goal', value: "generic"}, - }], - } - const output = defaultPhaseReducer(input); - expect(output).toEqual({ - id: 'phase-1', - name: 'Generic Phase', - nextPhaseId: 'end', - phaseData: { - norms: [], - goals: [{ - id: 'goal-1', - name: 'Generic Goal', - value: "generic" - }] - } - }); - }); - }) - describe("GraphReducer", () => { - test.each([ - { - state: onlyOnePhase, - expected: [ - { - id: 'phase-1', - name: 'Generic Phase', - nextPhaseId: 'end', - phaseData: { - norms: [], - goals: [] - } - }] - }, - { - state: onlyThreePhases, - expected: [ - { - id: 'phase-1', - name: 'Generic Phase', - nextPhaseId: 'phase-2', - phaseData: { - norms: [], - goals: [] - } - }, - { - id: 'phase-2', - name: 'Generic Phase', - nextPhaseId: 'phase-3', - phaseData: { - norms: [], - goals: [] - } - }, - { - id: 'phase-3', - name: 'Generic Phase', - nextPhaseId: 'end', - phaseData: { - norms: [], - goals: [] - } - }] - }, - { - state: onlySingleEdgeNorms, - expected: [ - { - id: 'phase-1', - name: 'Generic Phase', - nextPhaseId: 'phase-2', - phaseData: { - norms: [], - goals: [] - } - }, - { - id: 'phase-2', - name: 'Generic Phase', - nextPhaseId: 'phase-3', - phaseData: { - norms: [ - { - id: 'norm-1', - name: 'Generic Norm', - value: "generic" - } - ], - goals: [] - } - }, - { - id: 'phase-3', - name: 'Generic Phase', - nextPhaseId: 'end', - phaseData: { - norms: [{ - id: 'norm-2', - name: 'Generic Norm', - value: "generic" - }], - goals: [] - } - }] - }, - { - state: multiEdgeNorms, - expected: [ - { - id: 'phase-1', - name: 'Generic Phase', - nextPhaseId: 'phase-2', - phaseData: { - norms: [{ - id: 'norm-3', - name: 'Generic Norm', - value: "generic" - }], - goals: [] - } - }, - { - id: 'phase-2', - name: 'Generic Phase', - nextPhaseId: 'phase-3', - phaseData: { - norms: [ - { - id: 'norm-1', - name: 'Generic Norm', - value: "generic" - }, - { - id: 'norm-2', - name: 'Generic Norm', - value: "generic" - } - ], - goals: [] - } - }, - { - id: 'phase-3', - name: 'Generic Phase', - nextPhaseId: 'end', - phaseData: { - norms: [{ - id: 'norm-1', - name: 'Generic Norm', - value: "generic" - }, - { - id: 'norm-2', - name: 'Generic Norm', - value: "generic" - }], - goals: [] - } - }] - }, - { - state: onlyStartEnd, - expected: [], - } - ])(`tests state: $state.name`, ({state, expected}) => { - useFlowStore.setState({nodes: state.nodes, edges: state.edges}); - const output = graphReducer(); // uses default reducers - expect(output).toEqual(expected); - }) - // we run the test for correct error handling for the entire graph reducer as well, - // to make sure no errors occur before we intend to handle the errors ourselves - test.each([ - { - state: phaseConnectsToInvalidNodeType, - expected: new Error('| INVALID PROGRAM | the node "default-1" that "phase-1" connects to is not a phase or end node') - }, - { - state: phaseHasNoOutgoingConnections, - expected: new Error('| INVALID PROGRAM | the source handle of "phase-1" doesn\'t have any outgoing connections') - }, - { - state: phaseHasTooManyOutgoingConnections, - expected: new Error('| INVALID PROGRAM | the source handle of "phase-1" connects to too many targets') - } - ])(`tests erroneous state: $state.name`, ({state, expected}) => { - useFlowStore.setState({nodes: state.nodes, edges: state.edges}); - const testForError = () => { - graphReducer(); - }; - expect(testForError).toThrow(expected); - }) - }) -}); \ No newline at end of file +// describe('Graph Reducer Tests', () => { +// describe('defaultGraphPreprocessor', () => { +// test.each([ +// { +// state: onlyOnePhase, +// expected: [ +// { +// phaseNode: { +// id: 'phase-1', +// type: 'phase', +// position: {x: 0, y: 150}, +// data: {label: 'Generic Phase', number: 1}, +// }, +// nextPhaseId: 'end', +// connectedNorms: [], +// connectedGoals: [], +// }] +// }, +// { +// state: onlyThreePhases, +// expected: [ +// { +// phaseNode: { +// id: 'phase-1', +// type: 'phase', +// position: {x: 0, y: 150}, +// data: {label: 'Generic Phase', number: 1}, +// }, +// nextPhaseId: 'phase-2', +// connectedNorms: [], +// connectedGoals: [], +// }, +// { +// phaseNode: { +// id: 'phase-2', +// type: 'phase', +// position: {x: 0, y: 150}, +// data: {label: 'Generic Phase', number: 2}, +// }, +// nextPhaseId: 'phase-3', +// connectedNorms: [], +// connectedGoals: [], +// }, +// { +// phaseNode: { +// id: 'phase-3', +// type: 'phase', +// position: {x: 0, y: 150}, +// data: {label: 'Generic Phase', number: 3}, +// }, +// nextPhaseId: 'end', +// connectedNorms: [], +// connectedGoals: [], +// }] +// }, +// { +// state: onlySingleEdgeNorms, +// expected: [ +// { +// phaseNode: { +// id: 'phase-1', +// type: 'phase', +// position: {x: 0, y: 150}, +// data: {label: 'Generic Phase', number: 1}, +// }, +// nextPhaseId: 'phase-2', +// connectedNorms: [], +// connectedGoals: [], +// }, +// { +// phaseNode: { +// id: 'phase-2', +// type: 'phase', +// position: {x: 0, y: 150}, +// data: {label: 'Generic Phase', number: 2}, +// }, +// nextPhaseId: 'phase-3', +// connectedNorms: [{ +// id: 'norm-1', +// type: 'norm', +// position: {x: 0, y: 150}, +// data: {label: 'Generic Norm', value: "generic"}, +// }], +// connectedGoals: [], +// }, +// { +// phaseNode: { +// id: 'phase-3', +// type: 'phase', +// position: {x: 0, y: 150}, +// data: {label: 'Generic Phase', number: 3}, +// }, +// nextPhaseId: 'end', +// connectedNorms: [{ +// id: 'norm-2', +// type: 'norm', +// position: {x: 0, y: 150}, +// data: {label: 'Generic Norm', value: "generic"}, +// }], +// connectedGoals: [], +// }] +// }, +// { +// state: multiEdgeNorms, +// expected: [ +// { +// phaseNode: { +// id: 'phase-1', +// type: 'phase', +// position: {x: 0, y: 150}, +// data: {label: 'Generic Phase', number: 1}, +// }, +// nextPhaseId: 'phase-2', +// connectedNorms: [{ +// id: 'norm-3', +// type: 'norm', +// position: {x: 0, y: 150}, +// data: {label: 'Generic Norm', value: "generic"}, +// }], +// connectedGoals: [], +// }, +// { +// phaseNode: { +// id: 'phase-2', +// type: 'phase', +// position: {x: 0, y: 150}, +// data: {label: 'Generic Phase', number: 2}, +// }, +// nextPhaseId: 'phase-3', +// connectedNorms: [{ +// id: 'norm-1', +// type: 'norm', +// position: {x: 0, y: 150}, +// data: {label: 'Generic Norm', value: "generic"}, +// }, +// { +// id: 'norm-2', +// type: 'norm', +// position: {x: 0, y: 150}, +// data: {label: 'Generic Norm', value: "generic"}, +// }], +// connectedGoals: [], +// }, +// { +// phaseNode: { +// id: 'phase-3', +// type: 'phase', +// position: {x: 0, y: 150}, +// data: {label: 'Generic Phase', number: 3}, +// }, +// nextPhaseId: 'end', +// connectedNorms: [{ +// id: 'norm-1', +// type: 'norm', +// position: {x: 0, y: 150}, +// data: {label: 'Generic Norm', value: "generic"}, +// }, +// { +// id: 'norm-2', +// type: 'norm', +// position: {x: 0, y: 150}, +// data: {label: 'Generic Norm', value: "generic"}, +// }], +// connectedGoals: [], +// }] +// }, +// { +// state: onlyStartEnd, +// expected: [], +// } +// ])(`tests state: $state.name`, ({state, expected}) => { +// const output = defaultGraphPreprocessor(state.nodes, state.edges); +// expect(output).toEqual(expected); +// }); +// }); +// describe("orderPhases", () => { +// test.each([ +// { +// state: onlyOnePhase, +// expected: { +// phaseNodes: [{ +// id: 'phase-1', +// type: 'phase', +// position: {x: 0, y: 150}, +// data: {label: 'Generic Phase', number: 1}, +// }], +// connections: new Map([["phase-1","end"]]) +// } +// }, +// { +// state: onlyThreePhases, +// expected: { +// phaseNodes: [ +// { +// id: 'phase-1', +// type: 'phase', +// position: {x: 0, y: 150}, +// data: {label: 'Generic Phase', number: 1}, +// }, +// { +// id: 'phase-2', +// type: 'phase', +// position: {x: 0, y: 150}, +// data: {label: 'Generic Phase', number: 2}, +// }, +// { +// id: 'phase-3', +// type: 'phase', +// position: {x: 0, y: 150}, +// data: {label: 'Generic Phase', number: 3}, +// }], +// connections: new Map([ +// ["phase-1","phase-2"], +// ["phase-2","phase-3"], +// ["phase-3","end"] +// ]) +// } +// }, +// { +// state: onlySingleEdgeNorms, +// expected: { +// phaseNodes: [ +// { +// id: 'phase-1', +// type: 'phase', +// position: {x: 0, y: 150}, +// data: {label: 'Generic Phase', number: 1}, +// }, +// { +// id: 'phase-2', +// type: 'phase', +// position: {x: 0, y: 150}, +// data: {label: 'Generic Phase', number: 2}, +// }, +// { +// id: 'phase-3', +// type: 'phase', +// position: {x: 0, y: 150}, +// data: {label: 'Generic Phase', number: 3}, +// }], +// connections: new Map([ +// ["phase-1","phase-2"], +// ["phase-2","phase-3"], +// ["phase-3","end"] +// ]) +// } +// }, +// { +// state: onlyStartEnd, +// expected: { +// phaseNodes: [], +// connections: new Map() +// } +// } +// ])(`tests state: $state.name`, ({state, expected}) => { +// const output = orderPhases(state.nodes, state.edges); +// expect(output.phaseNodes).toEqual(expected.phaseNodes); +// expect(output.connections).toEqual(expected.connections); +// }); +// test.each([ +// { +// state: phaseConnectsToInvalidNodeType, +// expected: new Error('| INVALID PROGRAM | the node "default-1" that "phase-1" connects to is not a phase or end node') +// }, +// { +// state: phaseHasNoOutgoingConnections, +// expected: new Error('| INVALID PROGRAM | the source handle of "phase-1" doesn\'t have any outgoing connections') +// }, +// { +// state: phaseHasTooManyOutgoingConnections, +// expected: new Error('| INVALID PROGRAM | the source handle of "phase-1" connects to too many targets') +// } +// ])(`tests erroneous state: $state.name`, ({state, expected}) => { +// const testForError = () => { +// orderPhases(state.nodes, state.edges); +// }; +// expect(testForError).toThrow(expected); +// }) +// }) +// describe("defaultPhaseReducer", () => { +// test("phaseReducer handles empty norms and goals without failing", () => { +// const input : PreparedPhase = { +// phaseNode: { +// id: 'phase-1', +// type: 'phase', +// position: {x: 0, y: 150}, +// data: {label: 'Generic Phase', number: 1}, +// }, +// nextPhaseId: 'end', +// connectedNorms: [], +// connectedGoals: [], +// } +// const output = defaultPhaseReducer(input); +// expect(output).toEqual({ +// id: 'phase-1', +// name: 'Generic Phase', +// nextPhaseId: 'end', +// phaseData: { +// norms: [], +// goals: [] +// } +// }); +// }); +// test("defaultNormReducer reduces norms correctly", () => { +// const input : PreparedPhase = { +// phaseNode: { +// id: 'phase-1', +// type: 'phase', +// position: {x: 0, y: 150}, +// data: {label: 'Generic Phase', number: 1}, +// }, +// nextPhaseId: 'end', +// connectedNorms: [{ +// id: 'norm-1', +// type: 'norm', +// position: {x: 0, y: 150}, +// data: {label: 'Generic Norm', value: "generic"}, +// }], +// connectedGoals: [], +// } +// const output = defaultPhaseReducer(input); +// expect(output).toEqual({ +// id: 'phase-1', +// name: 'Generic Phase', +// nextPhaseId: 'end', +// phaseData: { +// norms: [{ +// id: 'norm-1', +// name: 'Generic Norm', +// value: "generic" +// }], +// goals: [] +// } +// }); +// }); +// test("defaultGoalReducer reduces goals correctly", () => { +// const input : PreparedPhase = { +// phaseNode: { +// id: 'phase-1', +// type: 'phase', +// position: {x: 0, y: 150}, +// data: {label: 'Generic Phase', number: 1}, +// }, +// nextPhaseId: 'end', +// connectedNorms: [], +// connectedGoals: [{ +// id: 'goal-1', +// type: 'goal', +// position: {x: 0, y: 150}, +// data: {label: 'Generic Goal', value: "generic"}, +// }], +// } +// const output = defaultPhaseReducer(input); +// expect(output).toEqual({ +// id: 'phase-1', +// name: 'Generic Phase', +// nextPhaseId: 'end', +// phaseData: { +// norms: [], +// goals: [{ +// id: 'goal-1', +// name: 'Generic Goal', +// value: "generic" +// }] +// } +// }); +// }); +// }) +// describe("GraphReducer", () => { +// test.each([ +// { +// state: onlyOnePhase, +// expected: [ +// { +// id: 'phase-1', +// name: 'Generic Phase', +// nextPhaseId: 'end', +// phaseData: { +// norms: [], +// goals: [] +// } +// }] +// }, +// { +// state: onlyThreePhases, +// expected: [ +// { +// id: 'phase-1', +// name: 'Generic Phase', +// nextPhaseId: 'phase-2', +// phaseData: { +// norms: [], +// goals: [] +// } +// }, +// { +// id: 'phase-2', +// name: 'Generic Phase', +// nextPhaseId: 'phase-3', +// phaseData: { +// norms: [], +// goals: [] +// } +// }, +// { +// id: 'phase-3', +// name: 'Generic Phase', +// nextPhaseId: 'end', +// phaseData: { +// norms: [], +// goals: [] +// } +// }] +// }, +// { +// state: onlySingleEdgeNorms, +// expected: [ +// { +// id: 'phase-1', +// name: 'Generic Phase', +// nextPhaseId: 'phase-2', +// phaseData: { +// norms: [], +// goals: [] +// } +// }, +// { +// id: 'phase-2', +// name: 'Generic Phase', +// nextPhaseId: 'phase-3', +// phaseData: { +// norms: [ +// { +// id: 'norm-1', +// name: 'Generic Norm', +// value: "generic" +// } +// ], +// goals: [] +// } +// }, +// { +// id: 'phase-3', +// name: 'Generic Phase', +// nextPhaseId: 'end', +// phaseData: { +// norms: [{ +// id: 'norm-2', +// name: 'Generic Norm', +// value: "generic" +// }], +// goals: [] +// } +// }] +// }, +// { +// state: multiEdgeNorms, +// expected: [ +// { +// id: 'phase-1', +// name: 'Generic Phase', +// nextPhaseId: 'phase-2', +// phaseData: { +// norms: [{ +// id: 'norm-3', +// name: 'Generic Norm', +// value: "generic" +// }], +// goals: [] +// } +// }, +// { +// id: 'phase-2', +// name: 'Generic Phase', +// nextPhaseId: 'phase-3', +// phaseData: { +// norms: [ +// { +// id: 'norm-1', +// name: 'Generic Norm', +// value: "generic" +// }, +// { +// id: 'norm-2', +// name: 'Generic Norm', +// value: "generic" +// } +// ], +// goals: [] +// } +// }, +// { +// id: 'phase-3', +// name: 'Generic Phase', +// nextPhaseId: 'end', +// phaseData: { +// norms: [{ +// id: 'norm-1', +// name: 'Generic Norm', +// value: "generic" +// }, +// { +// id: 'norm-2', +// name: 'Generic Norm', +// value: "generic" +// }], +// goals: [] +// } +// }] +// }, +// { +// state: onlyStartEnd, +// expected: [], +// } +// ])(`tests state: $state.name`, ({state, expected}) => { +// useFlowStore.setState({nodes: state.nodes, edges: state.edges}); +// const output = graphReducer(); // uses default reducers +// expect(output).toEqual(expected); +// }) +// // we run the test for correct error handling for the entire graph reducer as well, +// // to make sure no errors occur before we intend to handle the errors ourselves +// test.each([ +// { +// state: phaseConnectsToInvalidNodeType, +// expected: new Error('| INVALID PROGRAM | the node "default-1" that "phase-1" connects to is not a phase or end node') +// }, +// { +// state: phaseHasNoOutgoingConnections, +// expected: new Error('| INVALID PROGRAM | the source handle of "phase-1" doesn\'t have any outgoing connections') +// }, +// { +// state: phaseHasTooManyOutgoingConnections, +// expected: new Error('| INVALID PROGRAM | the source handle of "phase-1" connects to too many targets') +// } +// ])(`tests erroneous state: $state.name`, ({state, expected}) => { +// useFlowStore.setState({nodes: state.nodes, edges: state.edges}); +// const testForError = () => { +// graphReducer(); +// }; +// expect(testForError).toThrow(expected); +// }) +// }) +// }); \ No newline at end of file diff --git a/test/pages/visProgPage/visualProgrammingUI/components/DragDropSidebar.test.tsx b/test/pages/visProgPage/visualProgrammingUI/components/DragDropSidebar.test.tsx index a92adb3..9dde423 100644 --- a/test/pages/visProgPage/visualProgrammingUI/components/DragDropSidebar.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/components/DragDropSidebar.test.tsx @@ -1,33 +1,33 @@ -import { mockReactFlow } from '../../../../setupFlowTests.ts'; -import {act} from "@testing-library/react"; -import useFlowStore from "../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx"; -import {addNode} from "../../../../../src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx"; +// import { mockReactFlow } from '../../../../setupFlowTests.ts'; +// import {act} from "@testing-library/react"; +// import useFlowStore from "../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx"; +// import {addNode} from "../../../../../src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx"; -beforeAll(() => { - mockReactFlow(); -}); +// beforeAll(() => { +// mockReactFlow(); +// }); -describe('Drag-and-Drop sidebar', () => { - test.each(['phase', 'phase'])('new nodes get added correctly', (nodeType: string) => { - act(()=> { - addNode(nodeType, {x:100, y:100}); - }) - const updatedState = useFlowStore.getState(); - expect(updatedState.nodes.length).toBe(1); - expect(updatedState.nodes[0].type).toBe(nodeType); - }); - test.each(['phase', 'norm'])('new nodes get correct Id', (nodeType) => { - act(()=> { - addNode(nodeType, {x:100, y:100}); - addNode(nodeType, {x:100, y:100}); - }) - const updatedState = useFlowStore.getState(); - expect(updatedState.nodes.length).toBe(2); - expect(updatedState.nodes[0].id).toBe(`${nodeType}-1`); - expect(updatedState.nodes[1].id).toBe(`${nodeType}-2`); - }); - test('throws error on unexpected node type', () => { - expect(() => addNode('I do not Exist', {x:100, y:100})).toThrow("Node I do not Exist not found"); - }) -}); \ No newline at end of file +// describe('Drag-and-Drop sidebar', () => { +// test.each(['phase', 'phase'])('new nodes get added correctly', (nodeType: string) => { +// act(()=> { +// addNode(nodeType, {x:100, y:100}); +// }) +// const updatedState = useFlowStore.getState(); +// expect(updatedState.nodes.length).toBe(1); +// expect(updatedState.nodes[0].type).toBe(nodeType); +// }); +// test.each(['phase', 'norm'])('new nodes get correct Id', (nodeType) => { +// act(()=> { +// addNode(nodeType, {x:100, y:100}); +// addNode(nodeType, {x:100, y:100}); +// }) +// const updatedState = useFlowStore.getState(); +// expect(updatedState.nodes.length).toBe(2); +// expect(updatedState.nodes[0].id).toBe(`${nodeType}-1`); +// expect(updatedState.nodes[1].id).toBe(`${nodeType}-2`); +// }); +// test('throws error on unexpected node type', () => { +// expect(() => addNode('I do not Exist', {x:100, y:100})).toThrow("Node I do not Exist not found"); +// }) +// }); \ No newline at end of file -- 2.49.1 From 047e22ce4ddb88d9cc8cd767d2a78db67b6e5dde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Mon, 17 Nov 2025 16:15:39 +0100 Subject: [PATCH 100/184] chore: very small package fix --- package-lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index a1ed79f..f1728bf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1460,9 +1460,9 @@ } }, "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", "dev": true, "license": "MIT", "dependencies": { @@ -5908,9 +5908,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { -- 2.49.1 From 000d221538be54f423b85da3755184d3203ea409 Mon Sep 17 00:00:00 2001 From: Twirre Meulenbelt <43213592+TwirreM@users.noreply.github.com> Date: Tue, 18 Nov 2025 10:23:45 +0100 Subject: [PATCH 101/184] docs: introduce documentation generator ref: N25B-288 --- .gitignore | 5 +- README.md | 7 ++ package-lock.json | 216 ++++++++++++++++++++++++++++++++++++++++++++++ package.json | 1 + 4 files changed, 228 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 4147656..318a073 100644 --- a/.gitignore +++ b/.gitignore @@ -24,4 +24,7 @@ dist-ssr *.sw? # Coverage report -coverage \ No newline at end of file +coverage + +# Documentation pages (can be generated) +docs diff --git a/README.md b/README.md index 5646928..99d7057 100644 --- a/README.md +++ b/README.md @@ -41,3 +41,10 @@ branch name != /description-of-branch , commit name != : description of the commit. : N25B-Num's +## Documentation + +Generate documentation webpages with the command: + +```shell +typedoc --entryPointStrategy Expand src +``` diff --git a/package-lock.json b/package-lock.json index 40f413f..2cf2758 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "jest": "^30.2.0", "jest-environment-jsdom": "^30.2.0", "ts-jest": "^29.4.5", + "typedoc": "^0.28.14", "typescript": "~5.8.3", "typescript-eslint": "^8.44.0", "vite": "^7.1.7" @@ -1348,6 +1349,20 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@gerrit0/mini-shiki": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/@gerrit0/mini-shiki/-/mini-shiki-3.15.0.tgz", + "integrity": "sha512-L5IHdZIDa4bG4yJaOzfasOH/o22MCesY0mx+n6VATbaiCtMeR59pdRqYk4bEiQkIHfxsHPNgdi7VJlZb2FhdMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/engine-oniguruma": "^3.15.0", + "@shikijs/langs": "^3.15.0", + "@shikijs/themes": "^3.15.0", + "@shikijs/types": "^3.15.0", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -2351,6 +2366,55 @@ "win32" ] }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.15.0.tgz", + "integrity": "sha512-HnqFsV11skAHvOArMZdLBZZApRSYS4LSztk2K3016Y9VCyZISnlYUYsL2hzlS7tPqKHvNqmI5JSUJZprXloMvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.15.0", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, + "node_modules/@shikijs/langs": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.15.0.tgz", + "integrity": "sha512-WpRvEFvkVvO65uKYW4Rzxs+IG0gToyM8SARQMtGGsH4GDMNZrr60qdggXrFOsdfOVssG/QQGEl3FnJ3EZ+8w8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.15.0" + } + }, + "node_modules/@shikijs/themes": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.15.0.tgz", + "integrity": "sha512-8ow2zWb1IDvCKjYb0KiLNrK4offFdkfNVPXb1OZykpLCzRU6j+efkY+Y7VQjNlNFXonSw+4AOdGYtmqykDbRiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.15.0" + } + }, + "node_modules/@shikijs/types": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.15.0.tgz", + "integrity": "sha512-BnP+y/EQnhihgHy4oIAN+6FFtmfTekwOLsQbRw9hOKwqgNy8Bdsjq8B05oAt/ZgvIWWFrshV71ytOrlPfYjIJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "dev": true, + "license": "MIT" + }, "node_modules/@sinclair/typebox": { "version": "0.34.41", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz", @@ -2637,6 +2701,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -2738,6 +2812,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/yargs": { "version": "17.0.33", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", @@ -6055,6 +6136,16 @@ "dev": true, "license": "MIT" }, + "node_modules/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -6095,6 +6186,13 @@ "yallist": "^3.0.2" } }, + "node_modules/lunr": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/lunr/-/lunr-2.3.9.tgz", + "integrity": "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==", + "dev": true, + "license": "MIT" + }, "node_modules/lz-string": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", @@ -6152,6 +6250,44 @@ "tmpl": "1.0.5" } }, + "node_modules/markdown-it": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", + "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.4.0", + "linkify-it": "^5.0.0", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/markdown-it/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", + "dev": true, + "license": "MIT" + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -6704,6 +6840,16 @@ "node": ">=6" } }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/pure-rand": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", @@ -7602,6 +7748,56 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/typedoc": { + "version": "0.28.14", + "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.28.14.tgz", + "integrity": "sha512-ftJYPvpVfQvFzpkoSfHLkJybdA/geDJ8BGQt/ZnkkhnBYoYW6lBgPQXu6vqLxO4X75dA55hX8Af847H5KXlEFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@gerrit0/mini-shiki": "^3.12.0", + "lunr": "^2.3.9", + "markdown-it": "^14.1.0", + "minimatch": "^9.0.5", + "yaml": "^2.8.1" + }, + "bin": { + "typedoc": "bin/typedoc" + }, + "engines": { + "node": ">= 18", + "pnpm": ">= 10" + }, + "peerDependencies": { + "typescript": "5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x" + } + }, + "node_modules/typedoc/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/typedoc/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/typescript": { "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", @@ -7640,6 +7836,13 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "dev": true, + "license": "MIT" + }, "node_modules/uglify-js": { "version": "3.19.3", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", @@ -8142,6 +8345,19 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", + "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + } + }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", diff --git a/package.json b/package.json index cb88357..a493ed2 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "jest": "^30.2.0", "jest-environment-jsdom": "^30.2.0", "ts-jest": "^29.4.5", + "typedoc": "^0.28.14", "typescript": "~5.8.3", "typescript-eslint": "^8.44.0", "vite": "^7.1.7" -- 2.49.1 From eabc7c8b04507fc3c2afc4365e14cd03629a66a3 Mon Sep 17 00:00:00 2001 From: Twirre Meulenbelt <43213592+TwirreM@users.noreply.github.com> Date: Tue, 18 Nov 2025 11:45:10 +0100 Subject: [PATCH 102/184] docs: fix run command ref: N25B-288 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 99d7057..9d8fee2 100644 --- a/README.md +++ b/README.md @@ -46,5 +46,5 @@ commit name != : description of the commit. Generate documentation webpages with the command: ```shell -typedoc --entryPointStrategy Expand src +npx typedoc --entryPointStrategy Expand src ``` -- 2.49.1 From 3e73e78ee9b3cbce369335fb6e81e0454aac6073 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Tue, 18 Nov 2025 13:25:13 +0100 Subject: [PATCH 103/184] chore: merge the rest of the nodes back into this structure, and make sure that start and end nodes are not deletable. --- src/pages/VisProgPage/VisProg.tsx | 48 +++---- .../visualProgrammingUI/NodeRegistry.ts | 19 +++ .../visualProgrammingUI/VisProgStores.tsx | 23 ++- .../visualProgrammingUI/nodes/EndNode.tsx | 2 +- .../nodes/GoalNode.default.ts | 3 +- .../visualProgrammingUI/nodes/GoalNode.tsx | 57 ++++++-- .../visualProgrammingUI/nodes/StartNode.tsx | 2 +- .../nodes/TriggerNode.default.ts | 3 +- .../visualProgrammingUI/nodes/TriggerNode.tsx | 134 +++++++++++++++--- 9 files changed, 223 insertions(+), 68 deletions(-) diff --git a/src/pages/VisProgPage/VisProg.tsx b/src/pages/VisProgPage/VisProg.tsx index 70a0339..5489e3c 100644 --- a/src/pages/VisProgPage/VisProg.tsx +++ b/src/pages/VisProgPage/VisProg.tsx @@ -64,35 +64,34 @@ const VisProgUI = () => { } = useFlowStore(useShallow(selector)); // instructs the editor to use the corresponding functions from the FlowStore return ( -
    ); diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx index 741b190..925d1dd 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx @@ -1,12 +1,15 @@ import { type NodeProps, Position, - type Node, + type Node, useNodeConnections } from '@xyflow/react'; +import {useEffect} from "react"; import { Toolbar } from '../components/NodeComponents'; import styles from '../../VisProg.module.css'; import {SingleConnectionHandle} from "../components/RuleBasedHandle.tsx"; +import {type EditorWarning} from "../components/EditorWarnings.tsx"; import {allowOnlyConnectionsFromHandle} from "../HandleRules.ts"; +import useFlowStore from "../VisProgStores.tsx"; export type StartNodeData = { @@ -25,6 +28,27 @@ export type StartNode = Node * @returns React.JSX.Element */ export default function StartNode(props: NodeProps) { + const {registerWarning, unregisterWarning} = useFlowStore.getState(); + const connections = useNodeConnections({ + id: props.id, + handleId: 'source' + }) + + useEffect(() => { + const noConnectionWarning : EditorWarning = { + scope: { + id: props.id, + handleId: 'source' + }, + type: 'MISSING_OUTPUT', + severity: "ERROR", + description: "the startNode does not have an outgoing connection to a phaseNode" + } + + if (connections.length === 0) { registerWarning(noConnectionWarning); } + else { unregisterWarning(props.id, `${noConnectionWarning.type}:source`); } + }, [connections.length, props.id, registerWarning, unregisterWarning]); + return ( <> @@ -34,7 +58,7 @@ export default function StartNode(props: NodeProps) {
    -
    - - - {/* contains the drag and drop panel for nodes */} - - - - -
    +
    + + + {/* contains the drag and drop panel for nodes */} + + + +
    ); }; + /** * Places the VisProgUI component inside a ReactFlowProvider * @@ -112,6 +111,7 @@ function VisualProgrammingUI() { function runProgram() { const program = graphReducer(); console.log(program); + console.log(JSON.stringify(program, null, 2)); } /** diff --git a/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts b/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts index 6a98c0a..e02f5f2 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts @@ -6,12 +6,18 @@ import { EndNodeDefaults } from "./nodes/EndNode.default"; import { StartNodeDefaults } from "./nodes/StartNode.default"; import { PhaseNodeDefaults } from "./nodes/PhaseNode.default"; import { NormNodeDefaults } from "./nodes/NormNode.default"; +import GoalNode, { GoalConnects, GoalReduce } from "./nodes/GoalNode"; +import { GoalNodeDefaults } from "./nodes/GoalNode.default"; +import TriggerNode, { TriggerConnects, TriggerReduce } from "./nodes/TriggerNode"; +import { TriggerNodeDefaults } from "./nodes/TriggerNode.default"; export const NodeTypes = { start: StartNode, end: EndNode, phase: PhaseNode, norm: NormNode, + goal: GoalNode, + trigger: TriggerNode, }; // Default node data for creation @@ -20,6 +26,8 @@ export const NodeDefaults = { end: EndNodeDefaults, phase: PhaseNodeDefaults, norm: NormNodeDefaults, + goal: GoalNodeDefaults, + trigger: TriggerNodeDefaults, }; export const NodeReduces = { @@ -27,6 +35,8 @@ export const NodeReduces = { end: EndReduce, phase: PhaseReduce, norm: NormReduce, + goal: GoalReduce, + trigger: TriggerReduce, } export const NodeConnects = { @@ -34,4 +44,13 @@ export const NodeConnects = { end: EndConnects, phase: PhaseConnects, norm: NormConnects, + goal: GoalConnects, + trigger: TriggerConnects, +} + +// Function to tell the visual program if we're allowed to delete them... +// Right now it doesn't take in any values, but that could also be done later. +export const NodeDeletes = { + start: () => false, + end: () => false, } \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx index f38013f..49c296b 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx @@ -9,7 +9,7 @@ import { type XYPosition, } from '@xyflow/react'; import type { FlowState } from './VisProgTypes'; -import { NodeDefaults, NodeConnects } from './NodeRegistry'; +import { NodeDefaults, NodeConnects, NodeDeletes } from './NodeRegistry'; /** @@ -20,21 +20,22 @@ import { NodeDefaults, NodeConnects } from './NodeRegistry'; * @param data the data in the node to create * @constructor */ -function createNode(id: string, type: string, position: XYPosition, data: Record) { +function createNode(id: string, type: string, position: XYPosition, data: Record, deletable? : boolean) { const defaultData = NodeDefaults[type as keyof typeof NodeDefaults] const newData = { id: id, type: type, position: position, data: data, + deletable: deletable, } return {...defaultData, ...newData} } //* Initial nodes, created by using createNode. */ const initialNodes : Node[] = [ - createNode('start', 'start', {x: 100, y: 100}, {label: "Start"}), - createNode('end', 'end', {x: 370, y: 100}, {label: "End"}), + createNode('start', 'start', {x: 100, y: 100}, {label: "Start"}, false), + createNode('end', 'end', {x: 370, y: 100}, {label: "End"}, false), createNode('phase-1', 'phase', {x:200, y:100}, {label: "Phase 1", children: ['end', 'start']}), createNode('norms-1', 'norm', {x:-200, y:100}, {label: "Initial Norms", normList: ["Be a robot", "get good"]}), ]; @@ -92,12 +93,20 @@ const useFlowStore = create((set, get) => ({ set({ edgeReconnectSuccessful: true }); }, - deleteNode: (nodeId) => - set({ + deleteNode: (nodeId) => { + // Let's find our node to check if they have a special deletion function + const ourNode = get().nodes.find((n)=>n.id==nodeId); + const ourFunction = Object.entries(NodeDeletes).find(([t])=>t==ourNode?.type)?.[1] + + // If there's no function, OR, our function tells us we can delete it, let's do so... + if (ourFunction == undefined || ourFunction()) { + set({ nodes: get().nodes.filter((n) => n.id !== nodeId), edges: get().edges.filter((e) => e.source !== nodeId && e.target !== nodeId), - }), + })} + }, + setNodes: (nodes) => set({ nodes }), setEdges: (edges) => set({ edges }), diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx index c7007e6..b7159b6 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx @@ -18,7 +18,7 @@ export type EndNode = Node export default function EndNode(props: NodeProps) { return ( <> - +
    End diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.default.ts b/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.default.ts index a55832e..fc4d3aa 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.default.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.default.ts @@ -6,6 +6,7 @@ import type { GoalNodeData } from "./GoalNode"; export const GoalNodeDefaults: GoalNodeData = { label: "Goal Node", droppable: true, - GoalList: [], + description: "The robot will strive towards this goal", + achieved: false, hasReduce: true, }; \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx index 799c199..ce0b119 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx @@ -8,6 +8,8 @@ import { } from '@xyflow/react'; import { Toolbar } from '../components/NodeComponents'; import styles from '../../VisProg.module.css'; +import { TextField } from '../../../../components/TextField'; +import useFlowStore from '../VisProgStores'; /** * The default data dot a Goal node @@ -17,8 +19,9 @@ import styles from '../../VisProg.module.css'; */ export type GoalNodeData = { label: string; + description: string; droppable: boolean; - GoalList: string[]; + achieved: boolean; hasReduce: boolean; }; @@ -37,23 +40,47 @@ export function GoalNodeCanConnect(connection: Connection | Edge): boolean { * @returns React.JSX.Element */ export default function GoalNode(props: NodeProps) { - const label_input_id = `Goal_${props.id}_label_input`; const data = props.data as GoalNodeData; - return ( - <> - -
    -
    - - {props.data.label as string} -
    - {data.GoalList.map((Goal) => (
    {Goal}
    ))} - + const {updateNodeData} = useFlowStore(); + + const text_input_id = `goal_${props.id}_text_input`; + const checkbox_id = `goal_${props.id}_checkbox`; + + const setDescription = (value: string) => { + updateNodeData(props.id, {...data, description: value}); + } + + const setAchieved = (value: boolean) => { + updateNodeData(props.id, {...data, achieved: value}); + } + + return <> + +
    +
    + + setDescription(val)} + placeholder={"To ..."} + />
    - - ); +
    + + setAchieved(e.target.checked)} + /> +
    + +
    + ; } + /** * Reduces each Goal, including its children down into its relevant data. * @param props: The Node Properties of this node. @@ -66,7 +93,7 @@ export function GoalReduce(node: Node, nodes: Node[]) { const data = node.data as GoalNodeData; return { label: data.label, - list: data.GoalList, + achieved: data.achieved, } } diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx index a3a3ce6..d99a6ef 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx @@ -20,7 +20,7 @@ export type StartNode = Node export default function StartNode(props: NodeProps) { return ( <> - +
    Start diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.default.ts b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.default.ts index d3edeca..725f0d8 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.default.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.default.ts @@ -6,6 +6,7 @@ import type { TriggerNodeData } from "./TriggerNode"; export const TriggerNodeDefaults: TriggerNodeData = { label: "Trigger Node", droppable: true, - TriggerList: [], + triggers: [{id: "help-trigger", keyword:"help"}], + triggerType: "keywords", hasReduce: true, }; \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx index f9424ac..97a792e 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx @@ -8,6 +8,10 @@ import { } from '@xyflow/react'; import { Toolbar } from '../components/NodeComponents'; import styles from '../../VisProg.module.css'; +import useFlowStore from '../VisProgStores'; +import { useState } from 'react'; +import { RealtimeTextField, TextField } from '../../../../components/TextField'; +import duplicateIndices from '../../../../utils/duplicateIndices'; /** * The default data dot a Trigger node @@ -18,12 +22,12 @@ import styles from '../../VisProg.module.css'; export type TriggerNodeData = { label: string; droppable: boolean; - TriggerList: string[]; + triggerType: unknown; + triggers: [unknown]; hasReduce: boolean; }; - export type TriggerNode = Node @@ -37,21 +41,28 @@ export function TriggerNodeCanConnect(connection: Connection | Edge): boolean { * @returns React.JSX.Element */ export default function TriggerNode(props: NodeProps) { - const label_input_id = `Trigger_${props.id}_label_input`; - const data = props.data as TriggerNodeData; - return ( - <> - -
    -
    - - {props.data.label as string} -
    - {data.TriggerList.map((Trigger) => (
    {Trigger}
    ))} - -
    - - ); + const data = props.data as TriggerNodeData + const {updateNodeData} = useFlowStore(); + + const setKeywords = (keywords: Keyword[]) => { + updateNodeData(props.id, {...data, triggers: keywords}); + } + + return <> + +
    + {data.triggerType === "emotion" && ( +
    Emotion?
    + )} + {data.triggerType === "keywords" && ( + + )} + +
    + ; } /** @@ -66,7 +77,7 @@ export function TriggerReduce(node: Node, nodes: Node[]) { const data = node.data as TriggerNodeData; return { label: data.label, - list: data.TriggerList, + list: data.triggers, } } @@ -75,4 +86,91 @@ export function TriggerConnects(thisNode: Node, otherNode: Node, isThisSource: b if (thisNode == undefined && otherNode == undefined && isThisSource == false) { console.warn("Impossible node connection called in EndConnects") } +} + + +export type EmotionTriggerNodeProps = { + type: "emotion"; + value: string; +} + +type Keyword = { id: string, keyword: string }; + +export type KeywordTriggerNodeProps = { + type: "keywords"; + value: Keyword[]; +} + +export type TriggerNodeProps = EmotionTriggerNodeProps | KeywordTriggerNodeProps; + +function KeywordAdder({ addKeyword }: { addKeyword: (keyword: string) => void }) { + const [input, setInput] = useState(""); + + const text_input_id = "keyword_adder_input"; + + return
    + + { + if (!input) return; + addKeyword(input); + setInput(""); + }} + placeholder={"..."} + className={"flex-1"} + /> +
    ; +} + +function Keywords({ + keywords, + setKeywords, +}: { + keywords: Keyword[]; + setKeywords: (keywords: Keyword[]) => void; +}) { + type Interpolatable = string | number | boolean | bigint | null | undefined; + + const inputElementId = (id: Interpolatable) => `keyword_${id}_input`; + + /** Indices of duplicates in the keyword array. */ + const [duplicates, setDuplicates] = useState([]); + + function replace(id: string, value: string) { + value = value.trim(); + const newKeywords = value === "" + ? keywords.filter((kw) => kw.id != id) + : keywords.map((kw) => kw.id === id ? {...kw, keyword: value} : kw); + setKeywords(newKeywords); + setDuplicates(duplicateIndices(newKeywords.map((kw) => kw.keyword))); + } + + function add(value: string) { + value = value.trim(); + if (value === "") return; + const newKeywords = [...keywords, {id: crypto.randomUUID(), keyword: value}]; + setKeywords(newKeywords); + setDuplicates(duplicateIndices(newKeywords.map((kw) => kw.keyword))); + } + + return <> + Triggers when {keywords.length <= 1 ? "the keyword is" : "all keywords are"} spoken. + {[...keywords].map(({id, keyword}, index) => { + return
    + + replace(id, val)} + placeholder={"..."} + className={"flex-1"} + invalid={duplicates.includes(index)} + /> +
    ; + })} + + ; } \ No newline at end of file -- 2.49.1 From 0bbb6101ae97c5e9680bb3f1bcf80d5c18ba0c42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Tue, 18 Nov 2025 15:36:18 +0100 Subject: [PATCH 104/184] refactor: make sure that the droppable styles are kept, update some nodes to reflect their used functionality. ref: N25B-294 --- .../visualProgrammingUI/VisProgStores.tsx | 2 +- .../components/DragDropSidebar.tsx | 2 +- .../components/TriggerNodeComponent.tsx | 121 ------------------ .../visualProgrammingUI/nodes/EndNode.tsx | 23 +++- .../visualProgrammingUI/nodes/GoalNode.tsx | 7 +- .../visualProgrammingUI/nodes/NormNode.tsx | 34 ++++- .../visualProgrammingUI/nodes/PhaseNode.tsx | 2 +- .../visualProgrammingUI/nodes/StartNode.tsx | 2 - .../visualProgrammingUI/nodes/TriggerNode.tsx | 6 +- 9 files changed, 61 insertions(+), 138 deletions(-) delete mode 100644 src/pages/VisProgPage/visualProgrammingUI/components/TriggerNodeComponent.tsx diff --git a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx index 49c296b..607c817 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx @@ -114,7 +114,7 @@ const useFlowStore = create((set, get) => ({ set({ nodes: get().nodes.map((node) => { if (node.id === nodeId) { - node.data = { ...node.data, ...data }; + node = { ...node, data: { ...node.data, ...data }}; } return node; }), diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx index d59d821..b67f55f 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx @@ -124,7 +124,7 @@ export function DndToolbar() { } {droppableNodes.map(({type, data}) => ( diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/TriggerNodeComponent.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/TriggerNodeComponent.tsx deleted file mode 100644 index 9c0b342..0000000 --- a/src/pages/VisProgPage/visualProgrammingUI/components/TriggerNodeComponent.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import {Handle, type NodeProps, Position} from "@xyflow/react"; -import useFlowStore from "../VisProgStores.tsx"; -import styles from "../../VisProg.module.css"; -import {RealtimeTextField, TextField} from "../../../../components/TextField.tsx"; -import {Toolbar} from "./NodeComponents.tsx"; -import {useState} from "react"; -import duplicateIndices from "../../../../utils/duplicateIndices.ts"; -import type { TriggerNode } from "../nodes/TriggerNode.tsx"; - -export type EmotionTriggerNodeProps = { - type: "emotion"; - value: string; -} - -type Keyword = { id: string, keyword: string }; - -export type KeywordTriggerNodeProps = { - type: "keywords"; - value: Keyword[]; -} - -export type TriggerNodeProps = EmotionTriggerNodeProps | KeywordTriggerNodeProps; - -function KeywordAdder({ addKeyword }: { addKeyword: (keyword: string) => void }) { - const [input, setInput] = useState(""); - - const text_input_id = "keyword_adder_input"; - - return
    - - { - if (!input) return; - addKeyword(input); - setInput(""); - }} - placeholder={"..."} - className={"flex-1"} - /> -
    ; -} - -function Keywords({ - keywords, - setKeywords, -}: { - keywords: Keyword[]; - setKeywords: (keywords: Keyword[]) => void; -}) { - type Interpolatable = string | number | boolean | bigint | null | undefined; - - const inputElementId = (id: Interpolatable) => `keyword_${id}_input`; - - /** Indices of duplicates in the keyword array. */ - const [duplicates, setDuplicates] = useState([]); - - function replace(id: string, value: string) { - value = value.trim(); - const newKeywords = value === "" - ? keywords.filter((kw) => kw.id != id) - : keywords.map((kw) => kw.id === id ? {...kw, keyword: value} : kw); - setKeywords(newKeywords); - setDuplicates(duplicateIndices(newKeywords.map((kw) => kw.keyword))); - } - - function add(value: string) { - value = value.trim(); - if (value === "") return; - const newKeywords = [...keywords, {id: crypto.randomUUID(), keyword: value}]; - setKeywords(newKeywords); - setDuplicates(duplicateIndices(newKeywords.map((kw) => kw.keyword))); - } - - return <> - Triggers when {keywords.length <= 1 ? "the keyword is" : "all keywords are"} spoken. - {[...keywords].map(({id, keyword}, index) => { - return
    - - replace(id, val)} - placeholder={"..."} - className={"flex-1"} - invalid={duplicates.includes(index)} - /> -
    ; - })} - - ; -} - -// export default function TriggerNodeComponent({ -// id, -// data, -// }: NodeProps) { -// const {updateNodeData} = useFlowStore(); - -// const setKeywords = (keywords: Keyword[]) => { -// updateNodeData(id, {...data, value: keywords}); -// } - -// return <> -// -//
    -// {data.type === "emotion" && ( -//
    Emotion?
    -// )} -// {data.type === "keywords" && ( -// -// )} -// -//
    -// ; -// } diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx index b7159b6..c6f8f14 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx @@ -7,6 +7,9 @@ import { import { Toolbar } from '../components/NodeComponents'; import styles from '../../VisProg.module.css'; +/** + * The typing of this node's data + */ export type EndNodeData = { label: string; droppable: boolean; @@ -15,6 +18,11 @@ export type EndNodeData = { export type EndNode = Node +/** + * Default function to render an end node given its properties + * @param props the node's properties + * @returns React.JSX.Element + */ export default function EndNode(props: NodeProps) { return ( <> @@ -23,13 +31,18 @@ export default function EndNode(props: NodeProps) {
    End
    - - +
    ); } +/** + * Functionality for reducing this node into its more compact json program + * @param node the node to reduce + * @param nodes all nodes present + * @returns Dictionary, {id: node.id} + */ export function EndReduce(node: Node, nodes: Node[]) { // Replace this for nodes functionality if (nodes.length <= -1) { @@ -40,6 +53,12 @@ export function EndReduce(node: Node, nodes: Node[]) { } } +/** + * Any connection functionality that should get called when a connection is made to this node + * @param thisNode the node of which the functionality gets called + * @param otherNode the other node which has connected + * @param isThisSource whether this node is the one that is the source of the connection + */ export function EndConnects(thisNode: Node, otherNode: Node, isThisSource: boolean) { // Replace this for connection logic if (thisNode == undefined && otherNode == undefined && isThisSource == false) { diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx index ce0b119..322d6bb 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx @@ -12,10 +12,11 @@ import { TextField } from '../../../../components/TextField'; import useFlowStore from '../VisProgStores'; /** - * The default data dot a Goal node - * @param label: the label of this Goal + * The default data dot a phase node + * @param label: the label of this phase * @param droppable: whether this node is droppable from the drop bar (initialized as true) - * @param children: ID's of children of this node + * @param desciption: description of the goal + * @param hasReduce: whether this node has reducing functionality (true by default) */ export type GoalNodeData = { label: string; diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx index fde48ea..4dee91f 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx @@ -8,12 +8,14 @@ import { } from '@xyflow/react'; import { Toolbar } from '../components/NodeComponents'; import styles from '../../VisProg.module.css'; +import { TextField } from '../../../../components/TextField'; /** - * The default data dot a Norm node - * @param label: the label of this Norm + * The default data dot a phase node + * @param label: the label of this phase * @param droppable: whether this node is droppable from the drop bar (initialized as true) - * @param children: ID's of children of this node + * @param normList: list of strings of norms for this node + * @param hasReduce: whether this node has reducing functionality (true by default) */ export type NormNodeData = { label: string; @@ -47,7 +49,10 @@ export default function NormNode(props: NodeProps) { {props.data.label as string}
    - {data.normList.map((norm) => (
    {norm}
    ))} +
    + +
    +
    @@ -75,4 +80,25 @@ export function NormConnects(thisNode: Node, otherNode: Node, isThisSource: bool if (thisNode == undefined && otherNode == undefined && isThisSource == false) { console.warn("Impossible node connection called in EndConnects") } +} + +function Norms(props: { id: string; list: string[] }) { + const { id, list } = props; + return ( + <> + The norms that the robot will uphold: + { + list.map((norm, idx) => { + return ( +
    + { return; }} + /> +
    + ); + }) + } + + ); } \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx index 548753f..e6a6bfb 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx @@ -13,6 +13,7 @@ import { NodeDefaults, NodeReduces } from '../NodeRegistry'; * @param label: the label of this phase * @param droppable: whether this node is droppable from the drop bar (initialized as true) * @param children: ID's of children of this node + * @param hasReduce: whether this node has reducing functionality (true by default) */ export type PhaseNodeData = { label: string; @@ -43,7 +44,6 @@ export default function PhaseNode(props: NodeProps) { -
    ); diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx index d99a6ef..ac5bb0c 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx @@ -25,8 +25,6 @@ export default function StartNode(props: NodeProps) {
    Start
    - -
    diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx index 97a792e..299bc24 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx @@ -22,8 +22,8 @@ import duplicateIndices from '../../../../utils/duplicateIndices'; export type TriggerNodeData = { label: string; droppable: boolean; - triggerType: unknown; - triggers: [unknown]; + triggerType: "keywords" | string; + triggers: Keyword[] | never; hasReduce: boolean; }; @@ -56,7 +56,7 @@ export default function TriggerNode(props: NodeProps) { )} {data.triggerType === "keywords" && ( )} -- 2.49.1 From bb4e9d0b26a5d29998eaefb4ed289a1ac443d9cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Tue, 18 Nov 2025 18:47:08 +0100 Subject: [PATCH 105/184] fix: fixed the program reduce algorithm to be flexable and correctly use the different phase variables. ref: N25B-294 --- .../visualProgrammingUI/NodeRegistry.ts | 35 ++++++++-- .../visualProgrammingUI/VisProgStores.tsx | 5 +- .../visualProgrammingUI/nodes/GoalNode.tsx | 2 + .../nodes/NormNode.default.ts | 2 +- .../visualProgrammingUI/nodes/NormNode.tsx | 70 ++++++++----------- .../visualProgrammingUI/nodes/PhaseNode.tsx | 68 ++++++++++++++++-- .../nodes/TriggerNode.default.ts | 2 +- 7 files changed, 128 insertions(+), 56 deletions(-) diff --git a/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts b/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts index e02f5f2..0ef5455 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts @@ -1,6 +1,6 @@ import StartNode, { StartConnects, StartReduce } from "./nodes/StartNode"; import EndNode, { EndConnects, EndReduce } from "./nodes/EndNode"; -import PhaseNode, { PhaseConnects, PhaseReduce } from "./nodes/PhaseNode"; +import PhaseNode, { PhaseConnects, PhaseReduce, PhaseReduce2 } from "./nodes/PhaseNode"; import NormNode, { NormConnects, NormReduce } from "./nodes/NormNode"; import { EndNodeDefaults } from "./nodes/EndNode.default"; import { StartNodeDefaults } from "./nodes/StartNode.default"; @@ -11,6 +11,9 @@ import { GoalNodeDefaults } from "./nodes/GoalNode.default"; import TriggerNode, { TriggerConnects, TriggerReduce } from "./nodes/TriggerNode"; import { TriggerNodeDefaults } from "./nodes/TriggerNode.default"; +/** + * The types of the nodes we have registered. + */ export const NodeTypes = { start: StartNode, end: EndNode, @@ -20,7 +23,9 @@ export const NodeTypes = { trigger: TriggerNode, }; -// Default node data for creation +/** + * The default functions of the nodes we have registered. + */ export const NodeDefaults = { start: StartNodeDefaults, end: EndNodeDefaults, @@ -30,15 +35,23 @@ export const NodeDefaults = { trigger: TriggerNodeDefaults, }; + +/** + * The reduce functions of the nodes we have registered. + */ export const NodeReduces = { start: StartReduce, end: EndReduce, - phase: PhaseReduce, + phase: PhaseReduce2, norm: NormReduce, goal: GoalReduce, trigger: TriggerReduce, } + +/** + * The connection functionality of the nodes we have registered. + */ export const NodeConnects = { start: StartConnects, end: EndConnects, @@ -48,9 +61,21 @@ export const NodeConnects = { trigger: TriggerConnects, } -// Function to tell the visual program if we're allowed to delete them... -// Right now it doesn't take in any values, but that could also be done later. +/** + * Functions that define whether a node should be deleted, currently constant only for start and end. + * Any node types that aren't mentioned are 'true', and can be deleted by default. + */ export const NodeDeletes = { start: () => false, end: () => false, +} + +/** + * Defines which types are variables in the phase node- + * any node that is NOT mentioned here, is automatically seen as a variable of a phase. + */ +export const NodesInPhase = { + start: () => false, + end: () => false, + phase: () => false, } \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx index 607c817..e9c9bef 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx @@ -18,6 +18,7 @@ import { NodeDefaults, NodeConnects, NodeDeletes } from './NodeRegistry'; * @param id the id of the node to create * @param position the position of the node to create * @param data the data in the node to create + * @param deletable if this node should be able to be deleted IN ANY WAY POSSIBLE * @constructor */ function createNode(id: string, type: string, position: XYPosition, data: Record, deletable? : boolean) { @@ -35,8 +36,8 @@ function createNode(id: string, type: string, position: XYPosition, data: Record //* Initial nodes, created by using createNode. */ const initialNodes : Node[] = [ createNode('start', 'start', {x: 100, y: 100}, {label: "Start"}, false), - createNode('end', 'end', {x: 370, y: 100}, {label: "End"}, false), - createNode('phase-1', 'phase', {x:200, y:100}, {label: "Phase 1", children: ['end', 'start']}), + createNode('end', 'end', {x: 500, y: 100}, {label: "End"}, false), + createNode('phase-1', 'phase', {x:200, y:100}, {label: "Phase 1", children : []}), createNode('norms-1', 'norm', {x:-200, y:100}, {label: "Initial Norms", normList: ["Be a robot", "get good"]}), ]; diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx index 322d6bb..cf528c7 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx @@ -93,7 +93,9 @@ export function GoalReduce(node: Node, nodes: Node[]) { } const data = node.data as GoalNodeData; return { + id: node.id, label: data.label, + description: data.description, achieved: data.achieved, } } diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.default.ts b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.default.ts index 829085b..12cb182 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.default.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.default.ts @@ -6,6 +6,6 @@ import type { NormNodeData } from "./NormNode"; export const NormNodeDefaults: NormNodeData = { label: "Norm Node", droppable: true, - normList: [], + norm: "", hasReduce: true, }; \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx index 4dee91f..1d143da 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx @@ -9,18 +9,19 @@ import { import { Toolbar } from '../components/NodeComponents'; import styles from '../../VisProg.module.css'; import { TextField } from '../../../../components/TextField'; +import useFlowStore from '../VisProgStores'; /** * The default data dot a phase node * @param label: the label of this phase * @param droppable: whether this node is droppable from the drop bar (initialized as true) - * @param normList: list of strings of norms for this node + * @param norm: list of strings of norms for this node * @param hasReduce: whether this node has reducing functionality (true by default) */ export type NormNodeData = { label: string; droppable: boolean; - normList: string[]; + norm: string; hasReduce: boolean; }; @@ -39,25 +40,32 @@ export function NormNodeCanConnect(connection: Connection | Edge): boolean { * @returns React.JSX.Element */ export default function NormNode(props: NodeProps) { - const label_input_id = `Norm_${props.id}_label_input`; const data = props.data as NormNodeData; - return ( - <> - -
    -
    - - {props.data.label as string} -
    -
    - -
    - - + const {updateNodeData} = useFlowStore(); + + const text_input_id = `norm_${props.id}_text_input`; + + const setValue = (value: string) => { + updateNodeData(props.id, {norm: value}); + } + + return <> + +
    +
    + + setValue(val)} + placeholder={"Pepper should ..."} + />
    - - ); -} + +
    + ; +}; + /** * Reduces each Norm, including its children down into its relevant data. @@ -70,8 +78,9 @@ export function NormReduce(node: Node, nodes: Node[]) { } const data = node.data as NormNodeData; return { + id: node.id, label: data.label, - list: data.normList, + norm: data.norm, } } @@ -80,25 +89,4 @@ export function NormConnects(thisNode: Node, otherNode: Node, isThisSource: bool if (thisNode == undefined && otherNode == undefined && isThisSource == false) { console.warn("Impossible node connection called in EndConnects") } -} - -function Norms(props: { id: string; list: string[] }) { - const { id, list } = props; - return ( - <> - The norms that the robot will uphold: - { - list.map((norm, idx) => { - return ( -
    - { return; }} - /> -
    - ); - }) - } - - ); } \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx index e6a6bfb..864278d 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx @@ -6,7 +6,9 @@ import { } from '@xyflow/react'; import { Toolbar } from '../components/NodeComponents'; import styles from '../../VisProg.module.css'; -import { NodeDefaults, NodeReduces } from '../NodeRegistry'; +import { NodeDefaults, NodeReduces, NodesInPhase, NodeTypes } from '../NodeRegistry'; +import useFlowStore from '../VisProgStores'; +import { TextField } from '../../../../components/TextField'; /** * The default data dot a phase node @@ -32,22 +34,33 @@ export type PhaseNode = Node * @returns React.JSX.Element */ export default function PhaseNode(props: NodeProps) { + const data = props.data as PhaseNodeData; + const {updateNodeData} = useFlowStore(); + + const updateLabel = (value: string) => updateNodeData(props.id, {...data, label: value}); + const label_input_id = `phase_${props.id}_label_input`; + return ( <>
    - - {props.data.label as string} + +
    + -
    ); -} +}; /** * Reduces each phase, including its children down into its relevant data. @@ -86,10 +99,53 @@ export function PhaseReduce(node: Node, nodes: Node[]) { } } + +/** + * Reduces each phase, including its children down into its relevant data. + * @param props: The Node Properties of this node. + */ +export function PhaseReduce2(node: Node, nodes: Node[]) { + const thisnode = node as PhaseNode; + const data = thisnode.data as PhaseNodeData; + + // node typings that are not in phase + let nodesNotInPhase: string[] = Object.entries(NodesInPhase) + .filter(([, f]) => !f()) + .map(([t]) => t); + + // node typings that then are in phase + let nodesInPhase: string[] = Object.entries(NodeTypes) + .filter(([t]) => !nodesNotInPhase.includes(t)) + .map(([t]) => t); + + // children nodes + let childrenNodes = nodes.filter((node) => data.children.includes(node.id)); + + // Build the result object + let result: Record = { + id: thisnode.id, + label: data.label, + }; + + nodesInPhase.forEach((type) => { + let typedChildren = childrenNodes.filter((child) => child.type == type); + const reducer = NodeReduces[type as keyof typeof NodeReduces]; + if (!reducer) { + console.warn(`No reducer found for node type ${type}`); + result[type + "s"] = []; + } else { + result[type + "s"] = typedChildren.map((child) => reducer(child, nodes)); + } + }); + + return result; +} + + export function PhaseConnects(thisNode: Node, otherNode: Node, isThisSource: boolean) { console.log("Connect functionality called.") const node = thisNode as PhaseNode const data = node.data as PhaseNodeData - if (isThisSource) + if (!isThisSource) data.children.push(otherNode.id) } \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.default.ts b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.default.ts index 725f0d8..d1daf4a 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.default.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.default.ts @@ -6,7 +6,7 @@ import type { TriggerNodeData } from "./TriggerNode"; export const TriggerNodeDefaults: TriggerNodeData = { label: "Trigger Node", droppable: true, - triggers: [{id: "help-trigger", keyword:"help"}], + triggers: [], triggerType: "keywords", hasReduce: true, }; \ No newline at end of file -- 2.49.1 From bd7620a182cfc8cb61f55d62c06c85e6f028a0e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Tue, 18 Nov 2025 18:49:11 +0100 Subject: [PATCH 106/184] chore: fix eslints and spelling --- .../visualProgrammingUI/NodeRegistry.ts | 4 +- .../visualProgrammingUI/nodes/PhaseNode.tsx | 51 +++---------------- 2 files changed, 9 insertions(+), 46 deletions(-) diff --git a/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts b/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts index 0ef5455..14a993f 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts @@ -1,6 +1,6 @@ import StartNode, { StartConnects, StartReduce } from "./nodes/StartNode"; import EndNode, { EndConnects, EndReduce } from "./nodes/EndNode"; -import PhaseNode, { PhaseConnects, PhaseReduce, PhaseReduce2 } from "./nodes/PhaseNode"; +import PhaseNode, { PhaseConnects, PhaseReduce } from "./nodes/PhaseNode"; import NormNode, { NormConnects, NormReduce } from "./nodes/NormNode"; import { EndNodeDefaults } from "./nodes/EndNode.default"; import { StartNodeDefaults } from "./nodes/StartNode.default"; @@ -42,7 +42,7 @@ export const NodeDefaults = { export const NodeReduces = { start: StartReduce, end: EndReduce, - phase: PhaseReduce2, + phase: PhaseReduce, norm: NormReduce, goal: GoalReduce, trigger: TriggerReduce, diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx index 864278d..91d5486 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx @@ -6,7 +6,7 @@ import { } from '@xyflow/react'; import { Toolbar } from '../components/NodeComponents'; import styles from '../../VisProg.module.css'; -import { NodeDefaults, NodeReduces, NodesInPhase, NodeTypes } from '../NodeRegistry'; +import { NodeReduces, NodesInPhase, NodeTypes } from '../NodeRegistry'; import useFlowStore from '../VisProgStores'; import { TextField } from '../../../../components/TextField'; @@ -62,6 +62,7 @@ export default function PhaseNode(props: NodeProps) { ); }; + /** * Reduces each phase, including its children down into its relevant data. * @param props: The Node Properties of this node. @@ -69,66 +70,28 @@ export default function PhaseNode(props: NodeProps) { export function PhaseReduce(node: Node, nodes: Node[]) { const thisnode = node as PhaseNode; const data = thisnode.data as PhaseNodeData; - const reducableChildren = Object.entries(NodeDefaults) - .filter(([, data]) => data.hasReduce) - .map(([type]) => ( - type - )); - - let childrenData: unknown = "" - if (data.children != undefined) { - childrenData = data.children.map((childId) => { - // Reduce each of this phases' children. - const child = nodes.find((node) => node.id == childId); - - // Make sure that we reduce only valid children nodes. - if (child == undefined || child.type == undefined || !reducableChildren.includes(child.type)) return '' - const reducer = NodeReduces[child.type as keyof typeof NodeReduces] - - if (!reducer) { - console.warn(`No reducer found for node type ${child.type}`); - return null; - } - - return reducer(child, nodes); - })} - return { - id: thisnode.id, - name: data.label as string, - children: childrenData, - } -} - - -/** - * Reduces each phase, including its children down into its relevant data. - * @param props: The Node Properties of this node. - */ -export function PhaseReduce2(node: Node, nodes: Node[]) { - const thisnode = node as PhaseNode; - const data = thisnode.data as PhaseNodeData; // node typings that are not in phase - let nodesNotInPhase: string[] = Object.entries(NodesInPhase) + const nodesNotInPhase: string[] = Object.entries(NodesInPhase) .filter(([, f]) => !f()) .map(([t]) => t); // node typings that then are in phase - let nodesInPhase: string[] = Object.entries(NodeTypes) + const nodesInPhase: string[] = Object.entries(NodeTypes) .filter(([t]) => !nodesNotInPhase.includes(t)) .map(([t]) => t); // children nodes - let childrenNodes = nodes.filter((node) => data.children.includes(node.id)); + const childrenNodes = nodes.filter((node) => data.children.includes(node.id)); // Build the result object - let result: Record = { + const result: Record = { id: thisnode.id, label: data.label, }; nodesInPhase.forEach((type) => { - let typedChildren = childrenNodes.filter((child) => child.type == type); + const typedChildren = childrenNodes.filter((child) => child.type == type); const reducer = NodeReduces[type as keyof typeof NodeReduces]; if (!reducer) { console.warn(`No reducer found for node type ${type}`); -- 2.49.1 From 8c2e51114e69cc57ea66de1e54c058e6fa36228c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Tue, 18 Nov 2025 19:23:25 +0100 Subject: [PATCH 107/184] chore: delete graph tests that fail --- .../visualProgrammingUI/GraphReducer.test.ts | 982 ------------------ 1 file changed, 982 deletions(-) delete mode 100644 test/pages/visProgPage/visualProgrammingUI/GraphReducer.test.ts diff --git a/test/pages/visProgPage/visualProgrammingUI/GraphReducer.test.ts b/test/pages/visProgPage/visualProgrammingUI/GraphReducer.test.ts deleted file mode 100644 index de54ba2..0000000 --- a/test/pages/visProgPage/visualProgrammingUI/GraphReducer.test.ts +++ /dev/null @@ -1,982 +0,0 @@ -// import type {Edge} from "@xyflow/react"; -// import type {PreparedPhase} from "../../../../src/pages/VisProgPage/visualProgrammingUI/GraphReducerTypes.ts"; -// import useFlowStore from "../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx"; -// import type {AppNode} from "../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx"; - -// // sets of default values for nodes and edges to be used for test cases -// type FlowState = { -// name: string; -// nodes: AppNode[]; -// edges: Edge[]; -// }; - -// // predefined graphs for testing: -// const onlyOnePhase : FlowState = { -// name: "onlyOnePhase", -// nodes: [ -// { -// id: 'start', -// type: 'start', -// position: {x: 0, y: 0}, -// data: {label: 'start'} -// }, -// { -// id: 'phase-1', -// type: 'phase', -// position: {x: 0, y: 150}, -// data: {label: 'Generic Phase', number: 1}, -// }, -// { -// id: 'end', -// type: 'end', -// position: {x: 0, y: 300}, -// data: {label: 'End'} -// } -// ], -// edges:[ -// { -// id: 'start-phase-1', -// source: 'start', -// target: 'phase-1', -// }, -// { -// id: 'phase-1-end', -// source: 'phase-1', -// target: 'end', -// } -// ] -// }; -// const onlyThreePhases : FlowState = { -// name: "onlyThreePhases", -// nodes: [ -// { -// id: 'start', -// type: 'start', -// position: {x: 0, y: 0}, -// data: {label: 'start'} -// }, -// { -// id: 'phase-1', -// type: 'phase', -// position: {x: 0, y: 150}, -// data: {label: 'Generic Phase', number: 1}, -// }, -// { -// id: 'phase-3', -// type: 'phase', -// position: {x: 0, y: 150}, -// data: {label: 'Generic Phase', number: 3}, -// }, -// { -// id: 'phase-2', -// type: 'phase', -// position: {x: 0, y: 150}, -// data: {label: 'Generic Phase', number: 2}, -// }, -// { -// id: 'end', -// type: 'end', -// position: {x: 0, y: 300}, -// data: {label: 'End'} -// } -// ], -// edges:[ -// { -// id: 'start-phase-1', -// source: 'start', -// target: 'phase-1', -// }, -// { -// id: 'phase-1-phase-2', -// source: 'phase-1', -// target: 'phase-2', -// }, -// { -// id: 'phase-2-phase-3', -// source: 'phase-2', -// target: 'phase-3', -// }, -// { -// id: 'phase-3-end', -// source: 'phase-3', -// target: 'end', -// } -// ] -// }; -// const onlySingleEdgeNorms : FlowState = { -// name: "onlySingleEdgeNorms", -// nodes: [ -// { -// id: 'start', -// type: 'start', -// position: {x: 0, y: 0}, -// data: {label: 'start'} -// }, -// { -// id: 'phase-1', -// type: 'phase', -// position: {x: 0, y: 150}, -// data: {label: 'Generic Phase', number: 1}, -// }, -// { -// id: 'norm-1', -// type: 'norm', -// position: {x: 0, y: 150}, -// data: {label: 'Generic Norm', value: "generic"}, -// }, -// { -// id: 'norm-2', -// type: 'norm', -// position: {x: 0, y: 150}, -// data: {label: 'Generic Norm', value: "generic"}, -// }, -// { -// id: 'phase-3', -// type: 'phase', -// position: {x: 0, y: 150}, -// data: {label: 'Generic Phase', number: 3}, -// }, -// { -// id: 'phase-2', -// type: 'phase', -// position: {x: 0, y: 150}, -// data: {label: 'Generic Phase', number: 2}, -// }, -// { -// id: 'end', -// type: 'end', -// position: {x: 0, y: 300}, -// data: {label: 'End'} -// } -// ], -// edges:[ -// { -// id: 'start-phase-1', -// source: 'start', -// target: 'phase-1', -// }, -// { -// id: 'norm-1-phase-2', -// source: 'norm-1', -// target: 'phase-2', -// }, -// { -// id: 'phase-1-phase-2', -// source: 'phase-1', -// target: 'phase-2', -// }, -// { -// id: 'phase-2-phase-3', -// source: 'phase-2', -// target: 'phase-3', -// }, -// { -// id: 'norm-2-phase-3', -// source: 'norm-2', -// target: 'phase-3', -// }, -// { -// id: 'phase-3-end', -// source: 'phase-3', -// target: 'end', -// } -// ] -// }; -// const multiEdgeNorms : FlowState = { -// name: "multiEdgeNorms", -// nodes: [ -// { -// id: 'start', -// type: 'start', -// position: {x: 0, y: 0}, -// data: {label: 'start'} -// }, -// { -// id: 'phase-1', -// type: 'phase', -// position: {x: 0, y: 150}, -// data: {label: 'Generic Phase', number: 1}, -// }, -// { -// id: 'norm-1', -// type: 'norm', -// position: {x: 0, y: 150}, -// data: {label: 'Generic Norm', value: "generic"}, -// }, -// { -// id: 'norm-2', -// type: 'norm', -// position: {x: 0, y: 150}, -// data: {label: 'Generic Norm', value: "generic"}, -// }, -// { -// id: 'phase-3', -// type: 'phase', -// position: {x: 0, y: 150}, -// data: {label: 'Generic Phase', number: 3}, -// }, -// { -// id: 'norm-3', -// type: 'norm', -// position: {x: 0, y: 150}, -// data: {label: 'Generic Norm', value: "generic"}, -// }, -// { -// id: 'phase-2', -// type: 'phase', -// position: {x: 0, y: 150}, -// data: {label: 'Generic Phase', number: 2}, -// }, -// { -// id: 'end', -// type: 'end', -// position: {x: 0, y: 300}, -// data: {label: 'End'} -// } -// ], -// edges:[ -// { -// id: 'start-phase-1', -// source: 'start', -// target: 'phase-1', -// }, -// { -// id: 'norm-1-phase-2', -// source: 'norm-1', -// target: 'phase-2', -// }, -// { -// id: 'norm-1-phase-3', -// source: 'norm-1', -// target: 'phase-3', -// }, -// { -// id: 'phase-1-phase-2', -// source: 'phase-1', -// target: 'phase-2', -// }, -// { -// id: 'norm-3-phase-1', -// source: 'norm-3', -// target: 'phase-1', -// }, -// { -// id: 'phase-2-phase-3', -// source: 'phase-2', -// target: 'phase-3', -// }, -// { -// id: 'norm-2-phase-3', -// source: 'norm-2', -// target: 'phase-3', -// }, -// { -// id: 'norm-2-phase-2', -// source: 'norm-2', -// target: 'phase-2', -// }, -// { -// id: 'phase-3-end', -// source: 'phase-3', -// target: 'end', -// } -// ] -// }; -// const onlyStartEnd : FlowState = { -// name: "onlyStartEnd", -// nodes: [ -// { -// id: 'start', -// type: 'start', -// position: {x: 0, y: 0}, -// data: {label: 'start'} -// }, -// { -// id: 'end', -// type: 'end', -// position: {x: 0, y: 300}, -// data: {label: 'End'} -// } -// ], -// edges:[ -// { -// id: 'start-end', -// source: 'start', -// target: 'end', -// }, -// ] -// }; - -// // states that contain invalid programs for testing if correct errors are thrown: -// const phaseConnectsToInvalidNodeType : FlowState = { -// name: "phaseConnectsToInvalidNodeType", -// nodes: [ -// { -// id: 'start', -// type: 'start', -// position: {x: 0, y: 0}, -// data: {label: 'start'} -// }, -// { -// id: 'phase-1', -// type: 'phase', -// position: {x: 0, y: 150}, -// data: {label: 'Generic Phase', number: 1}, -// }, -// { -// id: 'default-1', -// type: 'default', -// position: {x: 0, y: 150}, -// data: {label: 'Generic Norm'}, -// }, -// { -// id: 'end', -// type: 'end', -// position: {x: 0, y: 300}, -// data: {label: 'End'} -// } -// ], -// edges:[ -// { -// id: 'start-phase-1', -// source: 'start', -// target: 'phase-1', -// }, -// { -// id: 'phase-1-default-1', -// source: 'phase-1', -// target: 'default-1', -// }, -// ] -// }; -// const phaseHasNoOutgoingConnections : FlowState = { -// name: "phaseHasNoOutgoingConnections", -// nodes: [ -// { -// id: 'start', -// type: 'start', -// position: {x: 0, y: 0}, -// data: {label: 'start'} -// }, -// { -// id: 'phase-1', -// type: 'phase', -// position: {x: 0, y: 150}, -// data: {label: 'Generic Phase', number: 1}, -// }, -// { -// id: 'phase-2', -// type: 'phase', -// position: {x: 0, y: 150}, -// data: {label: 'Generic Phase', number: 2}, -// }, -// { -// id: 'end', -// type: 'end', -// position: {x: 0, y: 300}, -// data: {label: 'End'} -// } -// ], -// edges:[ -// { -// id: 'start-phase-1', -// source: 'start', -// target: 'phase-1', -// }, -// ] -// }; -// const phaseHasTooManyOutgoingConnections : FlowState = { -// name: "phaseHasTooManyOutgoingConnections", -// nodes: [ -// { -// id: 'start', -// type: 'start', -// position: {x: 0, y: 0}, -// data: {label: 'start'} -// }, -// { -// id: 'phase-1', -// type: 'phase', -// position: {x: 0, y: 150}, -// data: {label: 'Generic Phase', number: 1}, -// }, -// { -// id: 'phase-2', -// type: 'phase', -// position: {x: 0, y: 150}, -// data: {label: 'Generic Phase', number: 2}, -// }, -// { -// id: 'end', -// type: 'end', -// position: {x: 0, y: 300}, -// data: {label: 'End'} -// } -// ], -// edges:[ -// { -// id: 'start-phase-1', -// source: 'start', -// target: 'phase-1', -// }, -// { -// id: 'phase-1-phase-2', -// source: 'phase-1', -// target: 'phase-2', -// }, -// { -// id: 'phase-1-end', -// source: 'phase-1', -// target: 'end', -// }, -// { -// id: 'phase-2-end', -// source: 'phase-2', -// target: 'end', -// }, -// ] -// }; - -// describe('Graph Reducer Tests', () => { -// describe('defaultGraphPreprocessor', () => { -// test.each([ -// { -// state: onlyOnePhase, -// expected: [ -// { -// phaseNode: { -// id: 'phase-1', -// type: 'phase', -// position: {x: 0, y: 150}, -// data: {label: 'Generic Phase', number: 1}, -// }, -// nextPhaseId: 'end', -// connectedNorms: [], -// connectedGoals: [], -// }] -// }, -// { -// state: onlyThreePhases, -// expected: [ -// { -// phaseNode: { -// id: 'phase-1', -// type: 'phase', -// position: {x: 0, y: 150}, -// data: {label: 'Generic Phase', number: 1}, -// }, -// nextPhaseId: 'phase-2', -// connectedNorms: [], -// connectedGoals: [], -// }, -// { -// phaseNode: { -// id: 'phase-2', -// type: 'phase', -// position: {x: 0, y: 150}, -// data: {label: 'Generic Phase', number: 2}, -// }, -// nextPhaseId: 'phase-3', -// connectedNorms: [], -// connectedGoals: [], -// }, -// { -// phaseNode: { -// id: 'phase-3', -// type: 'phase', -// position: {x: 0, y: 150}, -// data: {label: 'Generic Phase', number: 3}, -// }, -// nextPhaseId: 'end', -// connectedNorms: [], -// connectedGoals: [], -// }] -// }, -// { -// state: onlySingleEdgeNorms, -// expected: [ -// { -// phaseNode: { -// id: 'phase-1', -// type: 'phase', -// position: {x: 0, y: 150}, -// data: {label: 'Generic Phase', number: 1}, -// }, -// nextPhaseId: 'phase-2', -// connectedNorms: [], -// connectedGoals: [], -// }, -// { -// phaseNode: { -// id: 'phase-2', -// type: 'phase', -// position: {x: 0, y: 150}, -// data: {label: 'Generic Phase', number: 2}, -// }, -// nextPhaseId: 'phase-3', -// connectedNorms: [{ -// id: 'norm-1', -// type: 'norm', -// position: {x: 0, y: 150}, -// data: {label: 'Generic Norm', value: "generic"}, -// }], -// connectedGoals: [], -// }, -// { -// phaseNode: { -// id: 'phase-3', -// type: 'phase', -// position: {x: 0, y: 150}, -// data: {label: 'Generic Phase', number: 3}, -// }, -// nextPhaseId: 'end', -// connectedNorms: [{ -// id: 'norm-2', -// type: 'norm', -// position: {x: 0, y: 150}, -// data: {label: 'Generic Norm', value: "generic"}, -// }], -// connectedGoals: [], -// }] -// }, -// { -// state: multiEdgeNorms, -// expected: [ -// { -// phaseNode: { -// id: 'phase-1', -// type: 'phase', -// position: {x: 0, y: 150}, -// data: {label: 'Generic Phase', number: 1}, -// }, -// nextPhaseId: 'phase-2', -// connectedNorms: [{ -// id: 'norm-3', -// type: 'norm', -// position: {x: 0, y: 150}, -// data: {label: 'Generic Norm', value: "generic"}, -// }], -// connectedGoals: [], -// }, -// { -// phaseNode: { -// id: 'phase-2', -// type: 'phase', -// position: {x: 0, y: 150}, -// data: {label: 'Generic Phase', number: 2}, -// }, -// nextPhaseId: 'phase-3', -// connectedNorms: [{ -// id: 'norm-1', -// type: 'norm', -// position: {x: 0, y: 150}, -// data: {label: 'Generic Norm', value: "generic"}, -// }, -// { -// id: 'norm-2', -// type: 'norm', -// position: {x: 0, y: 150}, -// data: {label: 'Generic Norm', value: "generic"}, -// }], -// connectedGoals: [], -// }, -// { -// phaseNode: { -// id: 'phase-3', -// type: 'phase', -// position: {x: 0, y: 150}, -// data: {label: 'Generic Phase', number: 3}, -// }, -// nextPhaseId: 'end', -// connectedNorms: [{ -// id: 'norm-1', -// type: 'norm', -// position: {x: 0, y: 150}, -// data: {label: 'Generic Norm', value: "generic"}, -// }, -// { -// id: 'norm-2', -// type: 'norm', -// position: {x: 0, y: 150}, -// data: {label: 'Generic Norm', value: "generic"}, -// }], -// connectedGoals: [], -// }] -// }, -// { -// state: onlyStartEnd, -// expected: [], -// } -// ])(`tests state: $state.name`, ({state, expected}) => { -// const output = defaultGraphPreprocessor(state.nodes, state.edges); -// expect(output).toEqual(expected); -// }); -// }); -// describe("orderPhases", () => { -// test.each([ -// { -// state: onlyOnePhase, -// expected: { -// phaseNodes: [{ -// id: 'phase-1', -// type: 'phase', -// position: {x: 0, y: 150}, -// data: {label: 'Generic Phase', number: 1}, -// }], -// connections: new Map([["phase-1","end"]]) -// } -// }, -// { -// state: onlyThreePhases, -// expected: { -// phaseNodes: [ -// { -// id: 'phase-1', -// type: 'phase', -// position: {x: 0, y: 150}, -// data: {label: 'Generic Phase', number: 1}, -// }, -// { -// id: 'phase-2', -// type: 'phase', -// position: {x: 0, y: 150}, -// data: {label: 'Generic Phase', number: 2}, -// }, -// { -// id: 'phase-3', -// type: 'phase', -// position: {x: 0, y: 150}, -// data: {label: 'Generic Phase', number: 3}, -// }], -// connections: new Map([ -// ["phase-1","phase-2"], -// ["phase-2","phase-3"], -// ["phase-3","end"] -// ]) -// } -// }, -// { -// state: onlySingleEdgeNorms, -// expected: { -// phaseNodes: [ -// { -// id: 'phase-1', -// type: 'phase', -// position: {x: 0, y: 150}, -// data: {label: 'Generic Phase', number: 1}, -// }, -// { -// id: 'phase-2', -// type: 'phase', -// position: {x: 0, y: 150}, -// data: {label: 'Generic Phase', number: 2}, -// }, -// { -// id: 'phase-3', -// type: 'phase', -// position: {x: 0, y: 150}, -// data: {label: 'Generic Phase', number: 3}, -// }], -// connections: new Map([ -// ["phase-1","phase-2"], -// ["phase-2","phase-3"], -// ["phase-3","end"] -// ]) -// } -// }, -// { -// state: onlyStartEnd, -// expected: { -// phaseNodes: [], -// connections: new Map() -// } -// } -// ])(`tests state: $state.name`, ({state, expected}) => { -// const output = orderPhases(state.nodes, state.edges); -// expect(output.phaseNodes).toEqual(expected.phaseNodes); -// expect(output.connections).toEqual(expected.connections); -// }); -// test.each([ -// { -// state: phaseConnectsToInvalidNodeType, -// expected: new Error('| INVALID PROGRAM | the node "default-1" that "phase-1" connects to is not a phase or end node') -// }, -// { -// state: phaseHasNoOutgoingConnections, -// expected: new Error('| INVALID PROGRAM | the source handle of "phase-1" doesn\'t have any outgoing connections') -// }, -// { -// state: phaseHasTooManyOutgoingConnections, -// expected: new Error('| INVALID PROGRAM | the source handle of "phase-1" connects to too many targets') -// } -// ])(`tests erroneous state: $state.name`, ({state, expected}) => { -// const testForError = () => { -// orderPhases(state.nodes, state.edges); -// }; -// expect(testForError).toThrow(expected); -// }) -// }) -// describe("defaultPhaseReducer", () => { -// test("phaseReducer handles empty norms and goals without failing", () => { -// const input : PreparedPhase = { -// phaseNode: { -// id: 'phase-1', -// type: 'phase', -// position: {x: 0, y: 150}, -// data: {label: 'Generic Phase', number: 1}, -// }, -// nextPhaseId: 'end', -// connectedNorms: [], -// connectedGoals: [], -// } -// const output = defaultPhaseReducer(input); -// expect(output).toEqual({ -// id: 'phase-1', -// name: 'Generic Phase', -// nextPhaseId: 'end', -// phaseData: { -// norms: [], -// goals: [] -// } -// }); -// }); -// test("defaultNormReducer reduces norms correctly", () => { -// const input : PreparedPhase = { -// phaseNode: { -// id: 'phase-1', -// type: 'phase', -// position: {x: 0, y: 150}, -// data: {label: 'Generic Phase', number: 1}, -// }, -// nextPhaseId: 'end', -// connectedNorms: [{ -// id: 'norm-1', -// type: 'norm', -// position: {x: 0, y: 150}, -// data: {label: 'Generic Norm', value: "generic"}, -// }], -// connectedGoals: [], -// } -// const output = defaultPhaseReducer(input); -// expect(output).toEqual({ -// id: 'phase-1', -// name: 'Generic Phase', -// nextPhaseId: 'end', -// phaseData: { -// norms: [{ -// id: 'norm-1', -// name: 'Generic Norm', -// value: "generic" -// }], -// goals: [] -// } -// }); -// }); -// test("defaultGoalReducer reduces goals correctly", () => { -// const input : PreparedPhase = { -// phaseNode: { -// id: 'phase-1', -// type: 'phase', -// position: {x: 0, y: 150}, -// data: {label: 'Generic Phase', number: 1}, -// }, -// nextPhaseId: 'end', -// connectedNorms: [], -// connectedGoals: [{ -// id: 'goal-1', -// type: 'goal', -// position: {x: 0, y: 150}, -// data: {label: 'Generic Goal', value: "generic"}, -// }], -// } -// const output = defaultPhaseReducer(input); -// expect(output).toEqual({ -// id: 'phase-1', -// name: 'Generic Phase', -// nextPhaseId: 'end', -// phaseData: { -// norms: [], -// goals: [{ -// id: 'goal-1', -// name: 'Generic Goal', -// value: "generic" -// }] -// } -// }); -// }); -// }) -// describe("GraphReducer", () => { -// test.each([ -// { -// state: onlyOnePhase, -// expected: [ -// { -// id: 'phase-1', -// name: 'Generic Phase', -// nextPhaseId: 'end', -// phaseData: { -// norms: [], -// goals: [] -// } -// }] -// }, -// { -// state: onlyThreePhases, -// expected: [ -// { -// id: 'phase-1', -// name: 'Generic Phase', -// nextPhaseId: 'phase-2', -// phaseData: { -// norms: [], -// goals: [] -// } -// }, -// { -// id: 'phase-2', -// name: 'Generic Phase', -// nextPhaseId: 'phase-3', -// phaseData: { -// norms: [], -// goals: [] -// } -// }, -// { -// id: 'phase-3', -// name: 'Generic Phase', -// nextPhaseId: 'end', -// phaseData: { -// norms: [], -// goals: [] -// } -// }] -// }, -// { -// state: onlySingleEdgeNorms, -// expected: [ -// { -// id: 'phase-1', -// name: 'Generic Phase', -// nextPhaseId: 'phase-2', -// phaseData: { -// norms: [], -// goals: [] -// } -// }, -// { -// id: 'phase-2', -// name: 'Generic Phase', -// nextPhaseId: 'phase-3', -// phaseData: { -// norms: [ -// { -// id: 'norm-1', -// name: 'Generic Norm', -// value: "generic" -// } -// ], -// goals: [] -// } -// }, -// { -// id: 'phase-3', -// name: 'Generic Phase', -// nextPhaseId: 'end', -// phaseData: { -// norms: [{ -// id: 'norm-2', -// name: 'Generic Norm', -// value: "generic" -// }], -// goals: [] -// } -// }] -// }, -// { -// state: multiEdgeNorms, -// expected: [ -// { -// id: 'phase-1', -// name: 'Generic Phase', -// nextPhaseId: 'phase-2', -// phaseData: { -// norms: [{ -// id: 'norm-3', -// name: 'Generic Norm', -// value: "generic" -// }], -// goals: [] -// } -// }, -// { -// id: 'phase-2', -// name: 'Generic Phase', -// nextPhaseId: 'phase-3', -// phaseData: { -// norms: [ -// { -// id: 'norm-1', -// name: 'Generic Norm', -// value: "generic" -// }, -// { -// id: 'norm-2', -// name: 'Generic Norm', -// value: "generic" -// } -// ], -// goals: [] -// } -// }, -// { -// id: 'phase-3', -// name: 'Generic Phase', -// nextPhaseId: 'end', -// phaseData: { -// norms: [{ -// id: 'norm-1', -// name: 'Generic Norm', -// value: "generic" -// }, -// { -// id: 'norm-2', -// name: 'Generic Norm', -// value: "generic" -// }], -// goals: [] -// } -// }] -// }, -// { -// state: onlyStartEnd, -// expected: [], -// } -// ])(`tests state: $state.name`, ({state, expected}) => { -// useFlowStore.setState({nodes: state.nodes, edges: state.edges}); -// const output = graphReducer(); // uses default reducers -// expect(output).toEqual(expected); -// }) -// // we run the test for correct error handling for the entire graph reducer as well, -// // to make sure no errors occur before we intend to handle the errors ourselves -// test.each([ -// { -// state: phaseConnectsToInvalidNodeType, -// expected: new Error('| INVALID PROGRAM | the node "default-1" that "phase-1" connects to is not a phase or end node') -// }, -// { -// state: phaseHasNoOutgoingConnections, -// expected: new Error('| INVALID PROGRAM | the source handle of "phase-1" doesn\'t have any outgoing connections') -// }, -// { -// state: phaseHasTooManyOutgoingConnections, -// expected: new Error('| INVALID PROGRAM | the source handle of "phase-1" connects to too many targets') -// } -// ])(`tests erroneous state: $state.name`, ({state, expected}) => { -// useFlowStore.setState({nodes: state.nodes, edges: state.edges}); -// const testForError = () => { -// graphReducer(); -// }; -// expect(testForError).toThrow(expected); -// }) -// }) -// }); -- 2.49.1 From f37df1c7265e22ea68ecdfb243c0b1d46f650cba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Wed, 19 Nov 2025 10:13:08 +0100 Subject: [PATCH 108/184] chore: cleanup broken tests, add extra documentation, make sure everything is clean and code style isn't inconsistant --- src/pages/VisProgPage/VisProg.tsx | 2 +- .../visualProgrammingUI/NodeRegistry.ts | 1 + .../visualProgrammingUI/VisProgStores.tsx | 7 +++- .../components/DragDropSidebar.tsx | 20 +++++----- .../visualProgrammingUI/nodes/EndNode.tsx | 2 +- .../visualProgrammingUI/nodes/GoalNode.tsx | 8 ---- .../visualProgrammingUI/nodes/NormNode.tsx | 9 ----- .../visualProgrammingUI/nodes/PhaseNode.tsx | 16 ++++---- .../visualProgrammingUI/nodes/StartNode.tsx | 6 +++ .../visualProgrammingUI/nodes/TriggerNode.tsx | 16 ++++++-- .../visualProgrammingUI/GraphReducer.test.ts | 5 +++ .../components/DragDropSidebar.test.tsx | 38 +++---------------- 12 files changed, 56 insertions(+), 74 deletions(-) create mode 100644 test/pages/visProgPage/visualProgrammingUI/GraphReducer.test.ts diff --git a/src/pages/VisProgPage/VisProg.tsx b/src/pages/VisProgPage/VisProg.tsx index 5489e3c..c579c6c 100644 --- a/src/pages/VisProgPage/VisProg.tsx +++ b/src/pages/VisProgPage/VisProg.tsx @@ -141,4 +141,4 @@ function VisProgPage() { ) } -export default VisProgPage \ No newline at end of file +export default VisProgPage diff --git a/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts b/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts index 14a993f..ca8ef73 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts @@ -25,6 +25,7 @@ export const NodeTypes = { /** * The default functions of the nodes we have registered. + * These are defined in the .default.ts files. */ export const NodeDefaults = { start: StartNodeDefaults, diff --git a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx index e9c9bef..63164c2 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx @@ -47,6 +47,12 @@ const initialEdges: Edge[] = [ { id: 'phase-1-end', source: 'phase-1', target: 'end' }, ]; + +/** + * How we have defined the functions for our FlowState. + * We have the normal functionality of a default FlowState with some exceptions to account for extra functionality. + * The biggest changes are in onConnect and onDelete, which we have given extra functionality based on the nodes defined functions. + */ const useFlowStore = create((set, get) => ({ nodes: initialNodes, edges: initialEdges, @@ -56,7 +62,6 @@ const useFlowStore = create((set, get) => ({ set({nodes: applyNodeChanges(changes, get().nodes)}), onEdgesChange: (changes) => set({ edges: applyEdgeChanges(changes, get().edges) }), - // Let's make sure we tell the nodes when they're connected, and how it matters. onConnect: (connection) => { const edges = addEdge(connection, get().edges); const nodes = get().nodes; diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx index b67f55f..40f6dbd 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx @@ -47,12 +47,13 @@ function DraggableNode({ className, children, nodeType, onDrop }: DraggableNodeP * addNode — adds a new node to the flow using the unified class-based system. * Keeps numbering logic for phase/norm nodes. */ - function addNode(nodeType: keyof typeof NodeTypes, position: XYPosition) { +function addNode(nodeType: keyof typeof NodeTypes, position: XYPosition) { const { nodes, setNodes } = useFlowStore.getState(); - const defaultData = NodeDefaults[nodeType] - - if (!defaultData) throw new Error(`Node type '${nodeType}' not found in registry`); + // Find out if there's any default data about our ndoe + const defaultData = NodeDefaults[nodeType] ?? {} + + // Currently, we find out what the Id is by checking the last node and adding one const sameTypeNodes = nodes.filter((node) => node.type === nodeType); const nextNumber = sameTypeNodes.length > 0 @@ -63,9 +64,9 @@ function DraggableNode({ className, children, nodeType, onDrop }: DraggableNodeP return Number.isNaN(lastNum) ? sameTypeNodes.length + 1 : lastNum + 1; })() : 1; - const id = `${nodeType}-${nextNumber}`; + // Create new node const newNode = { id: id, type: nodeType, @@ -104,6 +105,7 @@ export function DndToolbar() { ); + // Map over our default settings to see which of them have their droppable data set to true const droppableNodes = Object.entries(NodeDefaults) .filter(([, data]) => data.droppable) .map(([type, data]) => ({ @@ -111,20 +113,16 @@ export function DndToolbar() { data })); - - return (
    You can drag these nodes to the pane to create new nodes.
    - { - // Maps over all the nodes that are droppable, and puts them in the panel - } + {/* Maps over all the nodes that are droppable, and puts them in the panel */} {droppableNodes.map(({type, data}) => ( diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx index c6f8f14..3de153d 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx @@ -54,7 +54,7 @@ export function EndReduce(node: Node, nodes: Node[]) { } /** - * Any connection functionality that should get called when a connection is made to this node + * Any connection functionality that should get called when a connection is made to this node type (end) * @param thisNode the node of which the functionality gets called * @param otherNode the other node which has connected * @param isThisSource whether this node is the one that is the source of the connection diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx index cf528c7..1461f6d 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx @@ -2,8 +2,6 @@ import { Handle, type NodeProps, Position, - type Connection, - type Edge, type Node, } from '@xyflow/react'; import { Toolbar } from '../components/NodeComponents'; @@ -26,15 +24,9 @@ export type GoalNodeData = { hasReduce: boolean; }; - - export type GoalNode = Node -export function GoalNodeCanConnect(connection: Connection | Edge): boolean { - return (connection != undefined); -} - /** * Defines how a Goal node should be rendered * @param props NodeProps, like id, label, children diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx index 1d143da..f9760af 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx @@ -2,8 +2,6 @@ import { Handle, type NodeProps, Position, - type Connection, - type Edge, type Node, } from '@xyflow/react'; import { Toolbar } from '../components/NodeComponents'; @@ -25,15 +23,8 @@ export type NormNodeData = { hasReduce: boolean; }; - - export type NormNode = Node - -export function NormNodeCanConnect(connection: Connection | Edge): boolean { - return (connection != undefined); -} - /** * Defines how a Norm node should be rendered * @param props NodeProps, like id, label, children diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx index 91d5486..9285c61 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx @@ -24,10 +24,8 @@ export type PhaseNodeData = { hasReduce: boolean; }; - export type PhaseNode = Node - /** * Defines how a phase node should be rendered * @param props NodeProps, like id, label, children @@ -36,9 +34,7 @@ export type PhaseNode = Node export default function PhaseNode(props: NodeProps) { const data = props.data as PhaseNodeData; const {updateNodeData} = useFlowStore(); - const updateLabel = (value: string) => updateNodeData(props.id, {...data, label: value}); - const label_input_id = `phase_${props.id}_label_input`; return ( @@ -62,10 +58,11 @@ export default function PhaseNode(props: NodeProps) { ); }; - /** * Reduces each phase, including its children down into its relevant data. - * @param props: The Node Properties of this node. + * @param node the node which is being reduced + * @param nodes all the nodes currently in the flow. + * @returns A collection of all reduced nodes in this phase, starting with this phases' reduced data. */ export function PhaseReduce(node: Node, nodes: Node[]) { const thisnode = node as PhaseNode; @@ -104,7 +101,12 @@ export function PhaseReduce(node: Node, nodes: Node[]) { return result; } - +/** + * This function is called whenever a connection is made with this node type (phase) + * @param thisNode the node of this node type which function is called + * @param otherNode the other node which was part of the connection + * @param isThisSource whether this instance of the node was the source in the connection, true = yes. + */ export function PhaseConnects(thisNode: Node, otherNode: Node, isThisSource: boolean) { console.log("Connect functionality called.") const node = thisNode as PhaseNode diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx index ac5bb0c..40e3865 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx @@ -41,6 +41,12 @@ export function StartReduce(node: Node, nodes: Node[]) { } } +/** + * This function is called whenever a connection is made with this node type (start) + * @param thisNode the node of this node type which function is called + * @param otherNode the other node which was part of the connection + * @param isThisSource whether this instance of the node was the source in the connection, true = yes. + */ export function StartConnects(thisNode: Node, otherNode: Node, isThisSource: boolean) { // Replace this for connection logic if (thisNode == undefined && otherNode == undefined && isThisSource == false) { diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx index 299bc24..6752d73 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx @@ -81,6 +81,12 @@ export function TriggerReduce(node: Node, nodes: Node[]) { } } +/** + * This function is called whenever a connection is made with this node type (trigger) + * @param thisNode the node of this node type which function is called + * @param otherNode the other node which was part of the connection + * @param isThisSource whether this instance of the node was the source in the connection, true = yes. + */ export function TriggerConnects(thisNode: Node, otherNode: Node, isThisSource: boolean) { // Replace this for connection logic if (thisNode == undefined && otherNode == undefined && isThisSource == false) { @@ -88,14 +94,13 @@ export function TriggerConnects(thisNode: Node, otherNode: Node, isThisSource: b } } +// Definitions for the possible triggers, being keywords and emotions +type Keyword = { id: string, keyword: string }; export type EmotionTriggerNodeProps = { type: "emotion"; value: string; } - -type Keyword = { id: string, keyword: string }; - export type KeywordTriggerNodeProps = { type: "keywords"; value: Keyword[]; @@ -103,6 +108,11 @@ export type KeywordTriggerNodeProps = { export type TriggerNodeProps = EmotionTriggerNodeProps | KeywordTriggerNodeProps; +/** + * The JSX element that is responsible for updating the field and showing the text + * @param param0 the function that updates the field + * @returns React.JSX.Element that handles adding keywords + */ function KeywordAdder({ addKeyword }: { addKeyword: (keyword: string) => void }) { const [input, setInput] = useState(""); diff --git a/test/pages/visProgPage/visualProgrammingUI/GraphReducer.test.ts b/test/pages/visProgPage/visualProgrammingUI/GraphReducer.test.ts new file mode 100644 index 0000000..192a7cf --- /dev/null +++ b/test/pages/visProgPage/visualProgrammingUI/GraphReducer.test.ts @@ -0,0 +1,5 @@ +describe('not yet implemented', () => { + test('nothing yet', () => { + expect(true); + }); +}); diff --git a/test/pages/visProgPage/visualProgrammingUI/components/DragDropSidebar.test.tsx b/test/pages/visProgPage/visualProgrammingUI/components/DragDropSidebar.test.tsx index 9dde423..70087ee 100644 --- a/test/pages/visProgPage/visualProgrammingUI/components/DragDropSidebar.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/components/DragDropSidebar.test.tsx @@ -1,33 +1,5 @@ -// import { mockReactFlow } from '../../../../setupFlowTests.ts'; -// import {act} from "@testing-library/react"; -// import useFlowStore from "../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx"; -// import {addNode} from "../../../../../src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx"; - - -// beforeAll(() => { -// mockReactFlow(); -// }); - -// describe('Drag-and-Drop sidebar', () => { -// test.each(['phase', 'phase'])('new nodes get added correctly', (nodeType: string) => { -// act(()=> { -// addNode(nodeType, {x:100, y:100}); -// }) -// const updatedState = useFlowStore.getState(); -// expect(updatedState.nodes.length).toBe(1); -// expect(updatedState.nodes[0].type).toBe(nodeType); -// }); -// test.each(['phase', 'norm'])('new nodes get correct Id', (nodeType) => { -// act(()=> { -// addNode(nodeType, {x:100, y:100}); -// addNode(nodeType, {x:100, y:100}); -// }) -// const updatedState = useFlowStore.getState(); -// expect(updatedState.nodes.length).toBe(2); -// expect(updatedState.nodes[0].id).toBe(`${nodeType}-1`); -// expect(updatedState.nodes[1].id).toBe(`${nodeType}-2`); -// }); -// test('throws error on unexpected node type', () => { -// expect(() => addNode('I do not Exist', {x:100, y:100})).toThrow("Node I do not Exist not found"); -// }) -// }); \ No newline at end of file +describe('Not implemented', () => { + test('nothing yet', () => { + expect(true) + }); +}); -- 2.49.1 From 1f70ebd799f0657cb5d26273420d1a4390fa9ad9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Wed, 19 Nov 2025 10:21:46 +0100 Subject: [PATCH 109/184] chore: remove a single console.log that wasn't needed... :) --- .../visualProgrammingUI/components/DragDropSidebar.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx index 40f6dbd..97b563b 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx @@ -73,8 +73,6 @@ function addNode(nodeType: keyof typeof NodeTypes, position: XYPosition) { position, data: {...defaultData} } - - console.log("Tried to add node"); setNodes([...nodes, newNode]); } -- 2.49.1 From c84f7307826e2da705366afad2dc108a0275a743 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Wed, 19 Nov 2025 17:31:13 +0000 Subject: [PATCH 110/184] Apply 1 suggestion(s) to 1 file(s) Co-authored-by: Twirre --- .../components/NodeComponents.tsx | 49 ------------------- 1 file changed, 49 deletions(-) diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/NodeComponents.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/NodeComponents.tsx index fde47b1..7eae77e 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/NodeComponents.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/NodeComponents.tsx @@ -32,52 +32,3 @@ export function Toolbar({nodeId, allowDelete}: ToolbarProps) { ); } -// Renaming component -/** - * Adds a component that can be used to edit a node's label entry inside its Data - * can be added to any custom node that has a label inside its Data - * - * @param {string} nodeLabel - * @param {string} nodeId - * @returns {React.JSX.Element} - * @constructor - */ -export function EditableName({nodeLabel = "new node", nodeId} : { nodeLabel : string, nodeId: string}) { - const {updateNodeData} = useFlowStore(); - - const updateData = (event: React.FocusEvent) => { - const input = event.target.value; - updateNodeData(nodeId, {label: input}); - event.currentTarget.setAttribute("readOnly", "true"); - window.getSelection()?.empty(); - event.currentTarget.classList.replace("nodrag", "drag"); // enable dragging of the node with cursor on the input box - }; - - const updateOnEnter = (event: React.KeyboardEvent) => { if (event.key === "Enter") (event.target as HTMLInputElement).blur(); }; - - const enableEditing = (event: React.MouseEvent) => { - if(event.currentTarget.hasAttribute("readOnly")) { - event.currentTarget.removeAttribute("readOnly"); // enable editing - event.currentTarget.select(); // select the text input - window.getSelection()?.collapseToEnd(); // move the caret to the end of the current value - event.currentTarget.classList.replace("drag", "nodrag"); // disable dragging using input box - } - } - - return ( -
    - - -
    - ) -} - -- 2.49.1 From 1dfc14ede87b64d09323a998af8f6a438e17eb08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Thu, 20 Nov 2025 14:33:23 +0100 Subject: [PATCH 111/184] chore: remove unused style reference --- .../visualProgrammingUI/components/NodeComponents.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/NodeComponents.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/NodeComponents.tsx index 7eae77e..524d494 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/NodeComponents.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/NodeComponents.tsx @@ -1,7 +1,6 @@ import { NodeToolbar} from '@xyflow/react'; import '@xyflow/react/dist/style.css'; -import styles from '../../VisProg.module.css'; import useFlowStore from "../VisProgStores.tsx"; //Toolbar definitions -- 2.49.1 From 79b645df88a30e3b3555ab3b4d514bfd2c152b4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Thu, 20 Nov 2025 14:53:42 +0100 Subject: [PATCH 112/184] chore: apply suggestions from threads for merge. --- src/pages/VisProgPage/VisProg.module.css | 25 ------------------- .../components/NodeComponents.tsx | 3 +-- .../visualProgrammingUI/nodes/EndNode.tsx | 2 +- .../visualProgrammingUI/nodes/GoalNode.tsx | 7 +++--- .../visualProgrammingUI/nodes/NormNode.tsx | 7 +++--- .../visualProgrammingUI/nodes/PhaseNode.tsx | 4 +-- .../visualProgrammingUI/nodes/StartNode.tsx | 14 ++++++++++- .../visualProgrammingUI/nodes/TriggerNode.tsx | 7 +++--- 8 files changed, 29 insertions(+), 40 deletions(-) diff --git a/src/pages/VisProgPage/VisProg.module.css b/src/pages/VisProgPage/VisProg.module.css index 7649429..250fba6 100644 --- a/src/pages/VisProgPage/VisProg.module.css +++ b/src/pages/VisProgPage/VisProg.module.css @@ -7,31 +7,6 @@ height: 100%; } - - -.node-text-input { - border: 1px solid transparent; - border-radius: 5pt; - padding: 4px 8px; - outline: none; - background-color: white; - transition: border-color 0.2s, box-shadow 0.2s; - cursor: text; -} - -.node-text-input:focus { - border-color: gainsboro; -} - -.node-text-input:read-only { - cursor: pointer; - background-color: whitesmoke; -} - -.node-text-input:read-only:hover { - border-color: gainsboro; -} - .dnd-panel { margin-inline-start: auto; margin-inline-end: auto; diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/NodeComponents.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/NodeComponents.tsx index 524d494..090fa38 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/NodeComponents.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/NodeComponents.tsx @@ -1,5 +1,4 @@ -import { - NodeToolbar} from '@xyflow/react'; +import { NodeToolbar } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; import useFlowStore from "../VisProgStores.tsx"; diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx index 3de153d..580499e 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx @@ -23,7 +23,7 @@ export type EndNode = Node * @param props the node's properties * @returns React.JSX.Element */ -export default function EndNode(props: NodeProps) { +export default function EndNode(props: NodeProps) { return ( <> diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx index 1461f6d..8cfa122 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx @@ -32,8 +32,8 @@ export type GoalNode = Node * @param props NodeProps, like id, label, children * @returns React.JSX.Element */ -export default function GoalNode(props: NodeProps) { - const data = props.data as GoalNodeData; +export default function GoalNode(props: NodeProps) { + const data = props.data const {updateNodeData} = useFlowStore(); const text_input_id = `goal_${props.id}_text_input`; @@ -76,7 +76,8 @@ export default function GoalNode(props: NodeProps) { /** * Reduces each Goal, including its children down into its relevant data. - * @param props: The Node Properties of this node. + * @param node: The Node Properties of this node. + * @param nodes: all the nodes in the graph */ export function GoalReduce(node: Node, nodes: Node[]) { // Replace this for nodes functionality diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx index f9760af..5789cac 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx @@ -30,8 +30,8 @@ export type NormNode = Node * @param props NodeProps, like id, label, children * @returns React.JSX.Element */ -export default function NormNode(props: NodeProps) { - const data = props.data as NormNodeData; +export default function NormNode(props: NodeProps) { + const data = props.data; const {updateNodeData} = useFlowStore(); const text_input_id = `norm_${props.id}_text_input`; @@ -60,7 +60,8 @@ export default function NormNode(props: NodeProps) { /** * Reduces each Norm, including its children down into its relevant data. - * @param props: The Node Properties of this node. + * @param node: The Node Properties of this node. + * @param nodes: all the nodes in the graph */ export function NormReduce(node: Node, nodes: Node[]) { // Replace this for nodes functionality diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx index 9285c61..7234e34 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx @@ -31,8 +31,8 @@ export type PhaseNode = Node * @param props NodeProps, like id, label, children * @returns React.JSX.Element */ -export default function PhaseNode(props: NodeProps) { - const data = props.data as PhaseNodeData; +export default function PhaseNode(props: NodeProps) { + const data = props.data; const {updateNodeData} = useFlowStore(); const updateLabel = (value: string) => updateNodeData(props.id, {...data, label: value}); const label_input_id = `phase_${props.id}_label_input`; diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx index 40e3865..6d74c08 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx @@ -17,7 +17,13 @@ export type StartNodeData = { export type StartNode = Node -export default function StartNode(props: NodeProps) { + +/** + * Defines how a Norm node should be rendered + * @param props NodeProps, like id, label, children + * @returns React.JSX.Element + */ +export default function StartNode(props: NodeProps) { return ( <> @@ -31,6 +37,12 @@ export default function StartNode(props: NodeProps) { ); } +/** + * The reduce function for this node type. + * @param node this node + * @param nodes all the nodes in the graph + * @returns a reduced structure of this node + */ export function StartReduce(node: Node, nodes: Node[]) { // Replace this for nodes functionality if (nodes.length <= -1) { diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx index 6752d73..a6f114e 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx @@ -40,8 +40,8 @@ export function TriggerNodeCanConnect(connection: Connection | Edge): boolean { * @param props NodeProps, like id, label, children * @returns React.JSX.Element */ -export default function TriggerNode(props: NodeProps) { - const data = props.data as TriggerNodeData +export default function TriggerNode(props: NodeProps) { + const data = props.data; const {updateNodeData} = useFlowStore(); const setKeywords = (keywords: Keyword[]) => { @@ -67,7 +67,8 @@ export default function TriggerNode(props: NodeProps) { /** * Reduces each Trigger, including its children down into its relevant data. - * @param props: The Node Properties of this node. + * @param node: The Node Properties of this node. + * @param nodes: all the nodes in the graph. */ export function TriggerReduce(node: Node, nodes: Node[]) { // Replace this for nodes functionality -- 2.49.1 From 690880faa4076d043ead1c7ebb237767fb2c4f93 Mon Sep 17 00:00:00 2001 From: Twirre Meulenbelt <43213592+TwirreM@users.noreply.github.com> Date: Tue, 25 Nov 2025 10:55:57 +0100 Subject: [PATCH 113/184] feat: send program to backend in the latest form ref: N25B-198 --- src/pages/VisProgPage/VisProg.tsx | 15 +++++++++-- .../visualProgrammingUI/nodes/TriggerNode.tsx | 26 +++++++++++++------ 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/src/pages/VisProgPage/VisProg.tsx b/src/pages/VisProgPage/VisProg.tsx index c579c6c..54ad43c 100644 --- a/src/pages/VisProgPage/VisProg.tsx +++ b/src/pages/VisProgPage/VisProg.tsx @@ -109,9 +109,20 @@ function VisualProgrammingUI() { // currently outputs the prepared program to the console function runProgram() { - const program = graphReducer(); - console.log(program); + const phases = graphReducer(); + const program = {phases} console.log(JSON.stringify(program, null, 2)); + fetch( + "http://localhost:8000/program", + { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify(program), + } + ).then((res) => { + if (!res.ok) throw new Error("Failed communicating with the backend.") + console.log("Successfully sent the program to the backend."); + }).catch(() => console.log("Failed to send program to the backend.")); } /** diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx index a6f114e..e60d954 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx @@ -67,19 +67,29 @@ export default function TriggerNode(props: NodeProps) { /** * Reduces each Trigger, including its children down into its relevant data. - * @param node: The Node Properties of this node. - * @param nodes: all the nodes in the graph. + * @param node The Node Properties of this node. + * @param nodes all the nodes in the graph. */ -export function TriggerReduce(node: Node, nodes: Node[]) { +export function TriggerReduce(node: TriggerNode, nodes: Node[]) { // Replace this for nodes functionality if (nodes.length <= -1) { console.warn("Impossible nodes length in TriggerReduce") } - const data = node.data as TriggerNodeData; - return { - label: data.label, - list: data.triggers, - } + const data = node.data; + switch (data.triggerType) { + case "keywords": + return { + id: node.id, + type: "keywords", + label: data.label, + keywords: data.triggers, + }; + default: + return { + ...data, + id: node.id, + }; + } } /** -- 2.49.1 From 32c8c985c3edf7309842345352a018a795c1e880 Mon Sep 17 00:00:00 2001 From: Twirre Meulenbelt <43213592+TwirreM@users.noreply.github.com> Date: Tue, 25 Nov 2025 11:06:11 +0100 Subject: [PATCH 114/184] chore: more general type required --- src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx index e60d954..cfeac8c 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx @@ -70,7 +70,7 @@ export default function TriggerNode(props: NodeProps) { * @param node The Node Properties of this node. * @param nodes all the nodes in the graph. */ -export function TriggerReduce(node: TriggerNode, nodes: Node[]) { +export function TriggerReduce(node: Node, nodes: Node[]) { // Replace this for nodes functionality if (nodes.length <= -1) { console.warn("Impossible nodes length in TriggerReduce") -- 2.49.1 From 10a2c0c3cd7fd761d14d687d633c8ef94c7e3f6b Mon Sep 17 00:00:00 2001 From: Arthur van Assenbergh Date: Wed, 26 Nov 2025 14:41:18 +0100 Subject: [PATCH 115/184] docs: create-and-check-documentation --- src/components/Logging/Filters.tsx | 72 +++++++++++++- src/components/Logging/Logging.tsx | 59 +++++++++++- src/components/Logging/useLogs.ts | 94 ++++++++++++++++--- src/components/ScrollIntoView.tsx | 10 +- src/components/TextField.tsx | 53 +++++++---- src/components/components.tsx | 8 ++ src/pages/ConnectedRobots/ConnectedRobots.tsx | 23 ++++- src/pages/Home/Home.tsx | 8 ++ src/pages/Robot/Robot.tsx | 38 +++++++- .../visualProgrammingUI/NodeRegistry.ts | 28 ++++-- .../visualProgrammingUI/VisProgStores.tsx | 59 +++++++++--- .../visualProgrammingUI/VisProgTypes.tsx | 57 ++++++++++- .../components/DragDropSidebar.tsx | 53 ++++++++--- .../components/NodeComponents.tsx | 17 ++-- .../visualProgrammingUI/nodes/GoalNode.tsx | 6 ++ .../visualProgrammingUI/nodes/NormNode.tsx | 6 ++ .../visualProgrammingUI/nodes/TriggerNode.tsx | 64 +++++++++---- src/utils/cellStore.ts | 67 +++++++++++++ 18 files changed, 625 insertions(+), 97 deletions(-) diff --git a/src/components/Logging/Filters.tsx b/src/components/Logging/Filters.tsx index 446a9c6..b98cc52 100644 --- a/src/components/Logging/Filters.tsx +++ b/src/components/Logging/Filters.tsx @@ -4,8 +4,15 @@ import type {LogFilterPredicate} from "./useLogs.ts"; import styles from "./Filters.module.css"; +/** + * A generic setter type compatible with React's state setters. + */ type Setter = (value: T | ((prev: T) => T)) => void; +/** + * Mapping of log level names to their corresponding numeric severity. + * Used for comparison in log filtering predicates. + */ const optionMapping = new Map([ ["ALL", 0], ["DEBUG", 10], @@ -16,6 +23,17 @@ const optionMapping = new Map([ ["NONE", 999_999_999_999], // It is technically possible to have a higher level, but this is fine ]); +/** + * Renders a single log-level selector (dropdown) for a specific filter target. + * + * Used by both the global filter and agent-specific filters. + * + * @param name - The display name or identifier for the filter target. + * @param level - The currently selected log level. + * @param setLevel - Function to update the selected log level. + * @param onDelete - Optional callback for deleting this filter element. + * @returns A JSX element that renders a labeled dropdown for selecting log levels. + */ function LevelPredicateElement({ name, level, @@ -54,8 +72,19 @@ function LevelPredicateElement({
    } +/** Key used for the global log-level predicate in the filter map. */ const GLOBAL_LOG_LEVEL_PREDICATE_KEY = "global_log_level"; +/** + * Renders and manages the **global log-level filter**. + * + * This component defines a baseline log level that all logs must meet or exceed + * to be displayed, unless overridden by per-agent filters. + * + * @param filterPredicates - Map of current log filter predicates. + * @param setFilterPredicates - Setter function to update the filter predicates map. + * @returns A JSX element rendering the global log-level selector. + */ function GlobalLevelFilter({ filterPredicates, setFilterPredicates, @@ -78,6 +107,7 @@ function GlobalLevelFilter({ }); } + // Initialize default global level on mount. useEffect(() => { if (filterPredicates.has(GLOBAL_LOG_LEVEL_PREDICATE_KEY)) return; setSelected("INFO"); @@ -91,8 +121,21 @@ function GlobalLevelFilter({ />; } +/** Prefix for agent-specific log-level predicate keys in the filter map. */ const AGENT_LOG_LEVEL_PREDICATE_KEY_PREFIX = "agent_log_level_"; +/** + * Renders and manages **per-agent log-level filters**. + * + * Allows the user to set specific log levels for individual agents, overriding + * the global filter for those agents. Includes functionality to add, edit, + * or remove agent-level filters. + * + * @param filterPredicates - Map of current log filter predicates. + * @param setFilterPredicates - Setter function to update the filter predicates map. + * @param agentNames - Set of agent names available for filtering. + * @returns A JSX element rendering agent-level filters and a dropdown to add new ones. + */ function AgentLevelFilters({ filterPredicates, setFilterPredicates, @@ -105,7 +148,7 @@ function AgentLevelFilters({ const rootRef = useRef(null); const [open, setOpen] = useState(false); - // Click outside to close + // Close dropdown or panels when clicking outside or pressing Escape. useEffect(() => { if (!open) return; const onDocClick = (e: MouseEvent) => { @@ -124,13 +167,16 @@ function AgentLevelFilters({ }; }, [open]); + // Identify which predicates correspond to agents. const agentPredicates = [...filterPredicates.keys()].filter((key) => key.startsWith(AGENT_LOG_LEVEL_PREDICATE_KEY_PREFIX)); - /** - * Create or change the predicate for an agent. If the level is not given, the global level is used. - * @param agentName The name of the agent. - * @param level The level to filter by. If not given, the global level is used. + /** + * Creates or updates the log filter predicate for a specific agent. + * Falls back to the global log level if no level is specified. + * + * @param agentName - The name of the agent to filter. + * @param level - Optional log level to apply; defaults to the global level. */ const setAgentPredicate = (agentName: string, level?: string ) => { level = level ?? filterPredicates.get(GLOBAL_LOG_LEVEL_PREDICATE_KEY)?.value ?? "ALL"; @@ -147,6 +193,11 @@ function AgentLevelFilters({ }); } + /** + * Deletes the log filter predicate for a specific agent. + * + * @param agentName - The name of the agent whose filter should be removed. + */ const deleteAgentPredicate = (agentName: string) => { setFilterPredicates((curr) => { const fullName = AGENT_LOG_LEVEL_PREDICATE_KEY_PREFIX + agentName; @@ -184,6 +235,17 @@ function AgentLevelFilters({ ; } +/** + * Main Filters component that aggregates global and per-agent log filters. + * + * Combines the global log-level filter and agent-specific filters into a unified UI. + * Updates a shared `Map` to determine which logs are shown. + * + * @param filterPredicates - The map of all active log filter predicates. + * @param setFilterPredicates - Setter to update the map of predicates. + * @param agentNames - Set of available agent names to display filters for. + * @returns A React component that renders all log filter controls. + */ export default function Filters({ filterPredicates, setFilterPredicates, diff --git a/src/components/Logging/Logging.tsx b/src/components/Logging/Logging.tsx index ede0bcc..8c2101f 100644 --- a/src/components/Logging/Logging.tsx +++ b/src/components/Logging/Logging.tsx @@ -8,13 +8,26 @@ import {type Cell, useCell} from "../../utils/cellStore.ts"; import styles from "./Logging.module.css"; + +/** + * Zustand store definition for managing user preferences related to logging. + * + * Includes flags for toggling relative timestamps and automatic scroll behavior. + */ type LoggingSettings = { + /** Whether to display log timestamps as relative (e.g., "2m 15s ago") instead of absolute. */ showRelativeTime: boolean; + /** Updates the `showRelativeTime` setting. */ setShowRelativeTime: (showRelativeTime: boolean) => void; + /** Whether the log view should automatically scroll to the newest entry. */ scrollToBottom: boolean; + /** Updates the `scrollToBottom` setting. */ setScrollToBottom: (scrollToBottom: boolean) => void; }; +/** + * Global Zustand store for logging UI preferences. + */ const useLoggingSettings = create((set) => ({ showRelativeTime: false, setShowRelativeTime: (showRelativeTime: boolean) => set({ showRelativeTime }), @@ -22,6 +35,16 @@ const useLoggingSettings = create((set) => ({ setScrollToBottom: (scrollToBottom: boolean) => set({ scrollToBottom }), })); +/** + * Renders a single log message entry with colored level indicators and timestamp formatting. + * + * This component automatically re-renders when the underlying log record (`recordCell`) + * changes. It also triggers the `onUpdate` callback whenever the record updates (e.g., for auto-scrolling). + * + * @param recordCell - A reactive `Cell` containing a single `LogRecord`. + * @param onUpdate - Optional callback triggered when the log entry updates. + * @returns A JSX element displaying a formatted log message. + */ function LogMessage({ recordCell, onUpdate, @@ -33,7 +56,8 @@ function LogMessage({ const record = useCell(recordCell); /** - * Normalizes the log level number to a multiple of 10, for which there are CSS styles. + * Normalizes the log level number to a multiple of 10, + * for which there are CSS styles. (e.g., INFO = 20, ERROR = 40). */ const normalizedLevelNo = (() => { // By default, the highest level is 50 (CRITICAL). Custom levels can be higher, but we don't have more critical color. @@ -42,8 +66,10 @@ function LogMessage({ return Math.round(record.levelno / 10) * 10; })(); + /** Simplifies the logger name by showing only the last path segment. */ const normalizedName = record.name.split(".").pop() || record.name; + // Notify parent component (e.g. for scroll updates) when this record changes. useEffect(() => { if (onUpdate) onUpdate(); }, [record, onUpdate]); @@ -65,11 +91,23 @@ function LogMessage({
    ; } +/** + * Displays a scrollable list of log messages. + * + * Handles: + * - Auto-scrolling when new messages arrive. + * - Allowing users to scroll manually and disable auto-scroll. + * - A floating "Scroll to bottom" button when not at the bottom. + * + * @param recordCells - Array of reactive log records to display. + * @returns A scrollable log list component. + */ function LogMessages({ recordCells }: { recordCells: Cell[] }) { const scrollableRef = useRef(null); const lastElementRef = useRef(null) const { scrollToBottom, setScrollToBottom } = useLoggingSettings(); + // Disable auto-scroll if the user manually scrolls. useEffect(() => { if (!scrollableRef.current) return; const currentScrollableRef = scrollableRef.current; @@ -85,6 +123,12 @@ function LogMessages({ recordCells }: { recordCells: Cell[] }) { } }, [scrollableRef, setScrollToBottom]); + /** + * Scrolls the last log message into view if auto-scroll is enabled, + * or if forced (e.g., user clicks "Scroll to bottom"). + * + * @param force - If true, forces scrolling even if `scrollToBottom` is false. + */ function scrollLastElementIntoView(force = false) { if ((!scrollToBottom && !force) || !lastElementRef.current) return; lastElementRef.current.scrollIntoView({ behavior: "smooth" }); @@ -111,6 +155,19 @@ function LogMessages({ recordCells }: { recordCells: Cell[] }) {
    ; } +/** + * Top-level logging panel component. + * + * Combines: + * - The `Filters` component for adjusting log visibility. + * - The `LogMessages` component for displaying filtered logs. + * - Zustand-managed UI settings (auto-scroll, timestamp display). + * + * This component uses the `useLogs` hook to fetch and filter logs based on + * active predicates, and re-renders automatically as new logs arrive. + * + * @returns The complete logging UI as a React element. + */ export default function Logging() { const [filterPredicates, setFilterPredicates] = useState(new Map()); const { filteredLogs, distinctNames } = useLogs(filterPredicates) diff --git a/src/components/Logging/useLogs.ts b/src/components/Logging/useLogs.ts index 76eed92..d51fdcb 100644 --- a/src/components/Logging/useLogs.ts +++ b/src/components/Logging/useLogs.ts @@ -3,6 +3,19 @@ import {useCallback, useEffect, useRef, useState} from "react"; import {applyPriorityPredicates, type PriorityFilterPredicate} from "../../utils/priorityFiltering.ts"; import {cell, type Cell} from "../../utils/cellStore.ts"; +/** + * Represents a single log record emitted by the backend logging system. + * + * @property name - The name of the logger or source (e.g., `"agent.core"`). + * @property message - The message content of the log record. + * @property levelname - The human-readable severity level (e.g., `"INFO"`, `"ERROR"`). + * @property levelno - The numeric severity value corresponding to `levelname`. + * @property created - The UNIX timestamp (in seconds) when this record was created. + * @property relativeCreated - The time (in milliseconds) since the logging system started. + * @property reference - (Optional) A reference identifier linking related log messages. + * @property firstCreated - Timestamp of the first log in this reference group. + * @property firstRelativeCreated - Relative timestamp of the first log in this reference group. + */ export type LogRecord = { name: string; message: string; @@ -15,29 +28,68 @@ export type LogRecord = { firstRelativeCreated: number; }; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type LogFilterPredicate = PriorityFilterPredicate & { value: any }; +/** + * A log filter predicate with priority support, used to determine whether + * a log record should be displayed. + * + * This extends a general `PriorityFilterPredicate` and includes an optional + * `value` field for UI metadata (e.g., selected log level or agent). + * + * @template T - The type of record being filtered (here, `LogRecord`). + */ +export type LogFilterPredicate = PriorityFilterPredicate & { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + value: any }; + /** + * React hook that manages the lifecycle of log records, including: + * - Receiving live log messages via Server-Sent Events (SSE), + * - Applying priority-based filtering rules, + * - Managing distinct logger names and reference-linked messages. + * + * Returns both the filtered logs (as reactive `Cell` objects) + * and a set of distinct logger names for use in UI components (e.g., Filters). + * + * @param filterPredicates - A `Map` of log filter predicates, keyed by ID or type. + * @returns An object containing: + * - `filteredLogs`: The currently visible (filtered) log messages. + * - `distinctNames`: A set of all distinct logger names encountered. + * + * @example + * ```ts + * const { filteredLogs, distinctNames } = useLogs(activeFilters); + * ``` + */ export function useLogs(filterPredicates: Map) { + /** Distinct logger names encountered across all logs. */ const [distinctNames, setDistinctNames] = useState>(new Set()); + /** Filtered logs that pass all active predicates, stored as reactive cells. */ const [filtered, setFiltered] = useState[]>([]); + /** Persistent reference to the active EventSource connection. */ const sseRef = useRef(null); + /** Keeps a stable reference to the current filter map (avoids re-renders). */ const filtersRef = useRef(filterPredicates); + /** Stores all received logs (the unfiltered full history). */ const logsRef = useRef([]); /** Map to store the first message for each reference, instance can be updated to change contents. */ const firstByRefRef = useRef>>(new Map()); /** - * Apply the filter predicates to a log record. + * Apply all active filter predicates to a log record. * @param log The log record to apply the filters to. - * @returns `true` if the record passes. + * @returns `true` if the record passes all filters; otherwise `false`. */ const applyFilters = useCallback((log: LogRecord) => applyPriorityPredicates(log, [...filtersRef.current.values()]), []); - /** Recomputes the entire filtered list. Use when filter predicates change. */ + /** + * Fully recomputes the filtered log list based on the current + * filter predicates and historical logs. + * + * Should be invoked whenever the filter map changes. + */ const recomputeFiltered = useCallback(() => { const newFiltered: Cell[] = []; firstByRefRef.current = new Map(); @@ -49,6 +101,7 @@ export function useLogs(filterPredicates: Map) { firstRelativeCreated: message.relativeCreated, }); + // Handle reference grouping: update the first message in the group. if (message.reference) { const first = firstByRefRef.current.get(message.reference); if (first) { @@ -59,14 +112,14 @@ export function useLogs(filterPredicates: Map) { firstRelativeCreated: prev.firstRelativeCreated ?? prev.relativeCreated, })); - // Don't add it to the list again - continue; + continue; // Don't add it to the list again (it's a duplicate). } else { // Add the first message with this reference to the registry firstByRefRef.current.set(message.reference, messageCell); } } + // Include only if it passes current filters. if (applyFilters(message)) { newFiltered.push(messageCell); } @@ -75,20 +128,23 @@ export function useLogs(filterPredicates: Map) { setFiltered(newFiltered); }, [applyFilters, setFiltered]); - // Reapply filters to all logs, only when filters change + // Re-filter all logs whenever filter predicates change. useEffect(() => { filtersRef.current = filterPredicates; recomputeFiltered(); }, [filterPredicates, recomputeFiltered]); /** - * Handle a new log message. Updates the filtered list and to the full history. - * @param message The new log message. + * Handles a newly received log record. + * Updates the full log history, distinct names set, and filtered log list. + * + * @param message - The new log record to process. */ const handleNewMessage = useCallback((message: LogRecord) => { - // Add to the full history for re-filtering on filter changes + // Store in complete history for future refiltering. logsRef.current.push(message); + // Track distinct logger names. setDistinctNames((prev) => { if (prev.has(message.name)) return prev; const newSet = new Set(prev); @@ -96,12 +152,14 @@ export function useLogs(filterPredicates: Map) { return newSet; }); + // Wrap in a reactive cell for UI binding. const messageCell = cell({ ...message, firstCreated: message.created, firstRelativeCreated: message.relativeCreated, }); + // Handle reference-linked updates. if (message.reference) { const first = firstByRefRef.current.get(message.reference); if (first) { @@ -112,20 +170,28 @@ export function useLogs(filterPredicates: Map) { firstRelativeCreated: prev.firstRelativeCreated ?? prev.relativeCreated, })); - // Don't add it to the list again - return; + return; // Do not duplicate reference group entries. } else { - // Add the first message with this reference to the registry firstByRefRef.current.set(message.reference, messageCell); } } + // Only append if message passes filters. if (applyFilters(message)) { setFiltered((curr) => [...curr, messageCell]); } }, [applyFilters, setFiltered]); + /** + * Initializes the SSE (Server-Sent Events) stream for real-time logs. + * + * Subscribes to messages from the backend logging endpoint and + * dispatches each message to `handleNewMessage`. + * + * Cleans up the EventSource connection when the component unmounts. + */ useEffect(() => { + // Only create one SSE connection for the lifetime of the hook. if (sseRef.current) return; const es = new EventSource("http://localhost:8000/logs/stream"); diff --git a/src/components/ScrollIntoView.tsx b/src/components/ScrollIntoView.tsx index bcbc7d4..df5148f 100644 --- a/src/components/ScrollIntoView.tsx +++ b/src/components/ScrollIntoView.tsx @@ -1,9 +1,17 @@ import {useEffect, useRef} from "react"; /** - * An element that always scrolls into view when it is rendered. When added to a list, the entire list will scroll to show this element. + * A React component that automatically scrolls itself into view whenever rendered. + * + * This component is especially useful in scrollable containers to keep the most + * recent content visible (e.g., chat applications, live logs, or notifications). + * + * It uses the browser's `Element.scrollIntoView()` API with smooth scrolling behavior. + * + * @returns A `
    ` element that scrolls into view when mounted or updated. */ export default function ScrollIntoView() { + /** Ref to the DOM element that will be scrolled into view. */ const elementRef = useRef(null); useEffect(() => { diff --git a/src/components/TextField.tsx b/src/components/TextField.tsx index 58de55d..f9527c8 100644 --- a/src/components/TextField.tsx +++ b/src/components/TextField.tsx @@ -2,15 +2,22 @@ import {useState} from "react"; import styles from "./TextField.module.css"; /** - * A text input element in our own style that calls `setValue` at every keystroke. + * A styled text input that updates its value **in real time** at every keystroke. * - * @param {Object} props - The component props. - * @param {string} props.value - The value of the text input. - * @param {(value: string) => void} props.setValue - A function that sets the value of the text input. - * @param {string} [props.placeholder] - The placeholder text for the text input. - * @param {string} [props.className] - Additional CSS classes for the text input. - * @param {string} [props.id] - The ID of the text input. - * @param {string} [props.ariaLabel] - The ARIA label for the text input. + * Automatically toggles between read-only and editable modes to integrate with + * drag-based UIs (like React Flow). Calls `onCommit` when editing is completed. + * + * @param props - Component properties. + * @param props.value - The current text input value. + * @param props.setValue - Callback invoked on every keystroke to update the value. + * @param props.onCommit - Callback invoked when editing is finalized (on blur or Enter). + * @param props.placeholder - Optional placeholder text displayed when the input is empty. + * @param props.className - Optional additional CSS class names. + * @param props.id - Optional unique HTML `id` for the input element. + * @param props.ariaLabel - Optional ARIA label for accessibility. + * @param props.invalid - If true, applies error styling to indicate invalid input. + * + * @returns A styled `` element that updates its value in real time. */ export function RealtimeTextField({ value = "", @@ -31,14 +38,19 @@ export function RealtimeTextField({ ariaLabel?: string, invalid?: boolean, }) { + /** Tracks whether the input is currently read-only (for drag compatibility). */ const [readOnly, setReadOnly] = useState(true); + /** Finalizes editing and calls `onCommit` when the user exits the field. */ const updateData = () => { setReadOnly(true); onCommit(); }; - const updateOnEnter = (event: React.KeyboardEvent) => { if (event.key === "Enter") (event.target as HTMLInputElement).blur(); }; + /** Handles the Enter key — commits the input by triggering a blur event. */ + const updateOnEnter = (event: React.KeyboardEvent) => { + if (event.key === "Enter") + (event.target as HTMLInputElement).blur(); }; return void} props.setValue - A function that sets the value of the text input. - * @param {string} [props.placeholder] - The placeholder text for the text input. - * @param {string} [props.className] - Additional CSS classes for the text input. - * @param {string} [props.id] - The ID of the text input. - * @param {string} [props.ariaLabel] - The ARIA label for the text input. + * Internally wraps `RealtimeTextField` and buffers input changes locally, + * calling `setValue` only once editing is complete. + * + * @param props - Component properties. + * @param props.value - The current text input value. + * @param props.setValue - Callback invoked when the user commits the change. + * @param props.placeholder - Optional placeholder text displayed when the input is empty. + * @param props.className - Optional additional CSS class names. + * @param props.id - Optional unique HTML `id` for the input element. + * @param props.ariaLabel - Optional ARIA label for accessibility. + * @param props.invalid - If true, applies error styling to indicate invalid input. + * + * @returns A styled `` element that updates its parent state only on commit. */ export function TextField({ value = "", diff --git a/src/components/components.tsx b/src/components/components.tsx index 24dd429..7ee7f0d 100644 --- a/src/components/components.tsx +++ b/src/components/components.tsx @@ -1,6 +1,14 @@ import { useState } from 'react' +/** + * A minimal counter component that demonstrates basic React state handling. + * + * Maintains an internal count value and provides buttons to increment and reset it. + * + * @returns A JSX element rendering the counter UI. + */ function Counter() { + /** The current counter value. */ const [count, setCount] = useState(0) return ( diff --git a/src/pages/ConnectedRobots/ConnectedRobots.tsx b/src/pages/ConnectedRobots/ConnectedRobots.tsx index b7ec65f..176f8d5 100644 --- a/src/pages/ConnectedRobots/ConnectedRobots.tsx +++ b/src/pages/ConnectedRobots/ConnectedRobots.tsx @@ -1,20 +1,35 @@ import { useEffect, useState } from 'react' +/** + * Displays the current connection status of a robot in real time. + * + * Opens an SSE connection to the backend (`/robot/ping_stream`) that emits + * simple boolean JSON messages (`true` or `false`). Updates automatically when + * the robot connects or disconnects. + * + * @returns A React element showing the current robot connection status. + */ export default function ConnectedRobots() { + /** + * The current connection state: + * - `true`: Robot is connected. + * - `false`: Robot is not connected. + * - `null`: Connection status is unknown (initial check in progress). + */ const [connected, setConnected] = useState(null); useEffect(() => { - // We're excepting a stream of data like that looks like this: `data = False` or `data = True` + // 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) => { - // Receive message and parse + // Expecting messages in JSON format: `true` or `false` console.log("received message:", event.data); try { const data = JSON.parse(event.data); - // Set connected to value. try { setConnected(data) } @@ -26,6 +41,8 @@ export default function ConnectedRobots() { console.log("Ping message not in correct format:", event.data); } }; + + // Clean up the SSE connection when the component unmounts. return () => eventSource.close(); }, []); diff --git a/src/pages/Home/Home.tsx b/src/pages/Home/Home.tsx index c6afa92..c998e25 100644 --- a/src/pages/Home/Home.tsx +++ b/src/pages/Home/Home.tsx @@ -2,6 +2,14 @@ import { Link } from 'react-router' import pepperLogo from '../../assets/pepper_transp2_small.svg' import styles from './Home.module.css' +/** + * The home page component providing navigation and project branding. + * + * Renders the Pepper logo and a set of navigational links + * implemented via React Router. + * + * @returns A JSX element representing the app’s home page. + */ function Home() { return (
    diff --git a/src/pages/Robot/Robot.tsx b/src/pages/Robot/Robot.tsx index 0038dd9..803b2f5 100644 --- a/src/pages/Robot/Robot.tsx +++ b/src/pages/Robot/Robot.tsx @@ -1,13 +1,34 @@ import { useState, useEffect, useRef } from 'react' +/** + * Displays a live robot interaction panel with user input, conversation history, + * and real-time updates from the robot backend via Server-Sent Events (SSE). + * + * @returns A React element rendering the interactive robot UI. + */ export default function Robot() { + /** The text message currently entered by the user. */ const [message, setMessage] = useState(''); + /** Whether the robot’s microphone or listening mode is currently active. */ const [listening, setListening] = useState(false); - const [conversation, setConversation] = useState<{"role": "user" | "assistant", "content": string}[]>([]) + /** The ongoing conversation history as a sequence of user/assistant messages. */ + const [conversation, setConversation] = useState< + {"role": "user" | "assistant", "content": string}[]>([]) + /** Reference to the scrollable conversation container for auto-scrolling. */ const conversationRef = useRef(null); + /** + * Index used to force refresh the SSE connection or clear conversation. + * Incrementing this value triggers a reset of the live data stream. + */ const [conversationIndex, setConversationIndex] = useState(0); + /** + * Sends a message to the robot backend. + * + * Makes a POST request to `/message` with the user’s text. + * The backend may respond with confirmation or error information. + */ const sendMessage = async () => { try { const response = await fetch("http://localhost:8000/message", { @@ -24,6 +45,17 @@ export default function Robot() { } }; + /** + * Establishes a persistent Server-Sent Events (SSE) connection + * to receive real-time updates from the robot backend. + * + * Handles three event types: + * - `voice_active`: whether the robot is currently listening. + * - `speech`: recognized user speech input. + * - `llm_response`: the robot’s language model-generated reply. + * + * The connection resets whenever `conversationIndex` changes. + */ useEffect(() => { const eventSource = new EventSource("http://localhost:8000/sse"); @@ -43,6 +75,10 @@ export default function Robot() { }; }, [conversationIndex]); + /** + * Automatically scrolls the conversation view to the bottom + * whenever a new message is added. + */ useEffect(() => { if (!conversationRef || !conversationRef.current) return; conversationRef.current.scrollTop = conversationRef.current.scrollHeight; diff --git a/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts b/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts index ca8ef73..e64acc1 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts @@ -12,7 +12,10 @@ import TriggerNode, { TriggerConnects, TriggerReduce } from "./nodes/TriggerNode import { TriggerNodeDefaults } from "./nodes/TriggerNode.default"; /** - * The types of the nodes we have registered. + * Registered node types in the visual programming system. + * + * Key: the node type string used in the flow graph. + * Value: the corresponding React component for rendering the node. */ export const NodeTypes = { start: StartNode, @@ -24,8 +27,9 @@ export const NodeTypes = { }; /** - * The default functions of the nodes we have registered. + * Default data and settings for each node type. * These are defined in the .default.ts files. + * These defaults are used when a new node is created to initialize its properties. */ export const NodeDefaults = { start: StartNodeDefaults, @@ -38,7 +42,10 @@ export const NodeDefaults = { /** - * The reduce functions of the nodes we have registered. + * Reduce functions for each node type. + * + * A reduce function extracts the relevant data from a node and its children. + * Used during graph evaluation or export. */ export const NodeReduces = { start: StartReduce, @@ -51,7 +58,9 @@ export const NodeReduces = { /** - * The connection functionality of the nodes we have registered. + * Connection functions for each node type. + * + * These functions define how nodes of a particular type can connect to other nodes. */ export const NodeConnects = { start: StartConnects, @@ -63,8 +72,9 @@ export const NodeConnects = { } /** - * Functions that define whether a node should be deleted, currently constant only for start and end. - * Any node types that aren't mentioned are 'true', and can be deleted by default. + * Defines whether a node type can be deleted. + * + * Returns a function per node type. Nodes not explicitly listed are deletable by default. */ export const NodeDeletes = { start: () => false, @@ -72,8 +82,10 @@ export const NodeDeletes = { } /** - * Defines which types are variables in the phase node- - * any node that is NOT mentioned here, is automatically seen as a variable of a phase. + * Defines which node types are considered variables in a phase node. + * + * Any node type not listed here is automatically treated as part of a phase. + * This allows the system to dynamically group nodes under a phase node. */ export const NodesInPhase = { start: () => false, diff --git a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx index 63164c2..e79715f 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx @@ -13,13 +13,14 @@ import { NodeDefaults, NodeConnects, NodeDeletes } from './NodeRegistry'; /** - * Create a node given the correct data - * @param type the type of the node to create - * @param id the id of the node to create - * @param position the position of the node to create - * @param data the data in the node to create - * @param deletable if this node should be able to be deleted IN ANY WAY POSSIBLE - * @constructor + * A Function to create a new node with the correct default data and properties. + * + * @param id - The unique ID of the node. + * @param type - The type of node to create (must exist in NodeDefaults). + * @param position - The XY position of the node in the flow canvas. + * @param data - The data object to initialize the node with. + * @param deletable - Optional flag to indicate if the node can be deleted (can be deleted by default). + * @returns A fully initialized Node object ready to be added to the flow. */ function createNode(id: string, type: string, position: XYPosition, data: Record, deletable? : boolean) { const defaultData = NodeDefaults[type as keyof typeof NodeDefaults] @@ -33,7 +34,7 @@ function createNode(id: string, type: string, position: XYPosition, data: Record return {...defaultData, ...newData} } -//* Initial nodes, created by using createNode. */ +//* Initial nodes to populate the flow at startup. const initialNodes : Node[] = [ createNode('start', 'start', {x: 100, y: 100}, {label: "Start"}, false), createNode('end', 'end', {x: 500, y: 100}, {label: "End"}, false), @@ -41,7 +42,7 @@ const initialNodes : Node[] = [ createNode('norms-1', 'norm', {x:-200, y:100}, {label: "Initial Norms", normList: ["Be a robot", "get good"]}), ]; -// * Initial edges * / +//* Initial edges to connect the startup nodes. const initialEdges: Edge[] = [ { id: 'start-phase-1', source: 'start', target: 'phase-1' }, { id: 'phase-1-end', source: 'phase-1', target: 'end' }, @@ -52,16 +53,33 @@ const initialEdges: Edge[] = [ * How we have defined the functions for our FlowState. * We have the normal functionality of a default FlowState with some exceptions to account for extra functionality. * The biggest changes are in onConnect and onDelete, which we have given extra functionality based on the nodes defined functions. + * + * * Provides: + * - Node and edge state management + * - Node creation, deletion, and updates + * - Custom connection handling via NodeConnects + * - Edge reconnection handling */ const useFlowStore = create((set, get) => ({ nodes: initialNodes, edges: initialEdges, edgeReconnectSuccessful: true, + /** + * Handles changes to nodes triggered by ReactFlow. + */ onNodesChange: (changes) => set({nodes: applyNodeChanges(changes, get().nodes)}), + + /** + * Handles changes to edges triggered by ReactFlow. + */ onEdgesChange: (changes) => set({ edges: applyEdgeChanges(changes, get().edges) }), + /** + * Handles creating a new connection between nodes. + * Updates edges and calls the node-specific connection functions. + */ onConnect: (connection) => { const edges = addEdge(connection, get().edges); const nodes = get().nodes; @@ -86,6 +104,9 @@ const useFlowStore = create((set, get) => ({ set({ nodes, edges }); }, + /** + * Handles reconnecting an edge between nodes. + */ onReconnect: (oldEdge, newConnection) => { get().edgeReconnectSuccessful = true; set({ edges: reconnectEdge(oldEdge, newConnection, get().edges) }); @@ -98,7 +119,11 @@ const useFlowStore = create((set, get) => ({ } set({ edgeReconnectSuccessful: true }); }, - + + /** + * Deletes a node by ID, respecting NodeDeletes rules. + * Also removes all edges connected to that node. + */ deleteNode: (nodeId) => { // Let's find our node to check if they have a special deletion function const ourNode = get().nodes.find((n)=>n.id==nodeId); @@ -112,10 +137,19 @@ const useFlowStore = create((set, get) => ({ })} }, - + /** + * Replaces the entire nodes array in the store. + */ setNodes: (nodes) => set({ nodes }), + + /** + * Replaces the entire edges array in the store. + */ setEdges: (edges) => set({ edges }), + /** + * Updates the data of a node by merging new data with existing data. + */ updateNodeData: (nodeId, data) => { set({ nodes: get().nodes.map((node) => { @@ -127,6 +161,9 @@ const useFlowStore = create((set, get) => ({ }); }, + /** + * Adds a new node to the flow store. + */ addNode: (node: Node) => { set({ nodes: [...get().nodes, node] }); }, diff --git a/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx b/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx index 6b98d6b..e466bed 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx @@ -2,23 +2,76 @@ import type { Edge, OnNodesChange, OnEdgesChange, OnConnect, OnReconnect, Node } from '@xyflow/react'; import type { NodeTypes } from './NodeRegistry'; -export type AppNode = typeof NodeTypes +/** + * Type representing all registered node types. + * This corresponds to the keys of NodeTypes in NodeRegistry. + */ +export type AppNode = typeof NodeTypes; +/** + * The FlowState type defines the shape of the Zustand store used for managing the visual programming flow. + * + * Includes: + * - Nodes and edges currently in the flow + * - Callbacks for node and edge changes + * - Node deletion and updates + * - Edge reconnection handling + */ export type FlowState = { nodes: Node[]; edges: Edge[]; edgeReconnectSuccessful: boolean; + /** Handler for changes to nodes triggered by ReactFlow */ onNodesChange: OnNodesChange; + + /** Handler for changes to edges triggered by ReactFlow */ onEdgesChange: OnEdgesChange; + + /** Handler for creating a new connection between nodes */ onConnect: OnConnect; + + /** Handler for reconnecting an existing edge */ onReconnect: OnReconnect; + + /** Called when an edge reconnect process starts */ onReconnectStart: () => void; + + /** + * Called when an edge reconnect process ends. + * @param _ - event or unused parameter + * @param edge - the edge that finished reconnecting + */ onReconnectEnd: (_: unknown, edge: { id: string }) => void; + /** + * Deletes a node and any connected edges. + * @param nodeId - the ID of the node to delete + */ deleteNode: (nodeId: string) => void; + + /** + * Replaces the current nodes array in the store. + * @param nodes - new array of nodes + */ setNodes: (nodes: Node[]) => void; + + /** + * Replaces the current edges array in the store. + * @param edges - new array of edges + */ setEdges: (edges: Edge[]) => void; + + /** + * Updates the data of a node by merging new data with existing node data. + * @param nodeId - the ID of the node to update + * @param data - object containing new data fields to merge + */ updateNodeData: (nodeId: string, data: object) => void; + + /** + * Adds a new node to the flow. + * @param node - the Node object to add + */ addNode: (node: Node) => void; -}; \ No newline at end of file +}; diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx index 97b563b..92f211c 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx @@ -6,7 +6,12 @@ import styles from '../../VisProg.module.css'; import { NodeDefaults, type NodeTypes } from '../NodeRegistry' /** - * DraggableNodeProps dictates the type properties of a DraggableNode + * Props for a draggable node within the drag-and-drop toolbar. + * + * @property className - Optional custom CSS classes for styling. + * @property children - The visual content or label rendered inside the draggable node. + * @property nodeType - The type of node represented (key from `NodeTypes`). + * @property onDrop - Function called when the node is dropped on the flow pane. */ interface DraggableNodeProps { className?: string; @@ -16,15 +21,20 @@ interface DraggableNodeProps { } /** - * Definition of a node inside the drag and drop toolbar. - * These nodes require an onDrop function that dictates - * how the node is created in the graph. + * A draggable node element used in the drag-and-drop toolbar. + * + * Integrates with the NeoDrag library to handle drag events. + * On drop, it calls the provided `onDrop` function with the node type and drop position. + * + * @param props - The draggable node configuration. + * @returns A React element representing a draggable node. */ function DraggableNode({ className, children, nodeType, onDrop }: DraggableNodeProps) { const draggableRef = useRef(null); const [position, setPosition] = useState({ x: 0, y: 0 }); - // @ts-expect-error from the neodrag package — safe to ignore + // The NeoDrag hook enables smooth drag functionality for this element. + // @ts-expect-error: NeoDrag typing incompatibility — safe to ignore. useDraggable(draggableRef, { position, onDrag: ({ offsetX, offsetY }) => { @@ -44,16 +54,23 @@ function DraggableNode({ className, children, nodeType, onDrop }: DraggableNodeP } /** - * addNode — adds a new node to the flow using the unified class-based system. - * Keeps numbering logic for phase/norm nodes. + * Adds a new node to the flow graph. + * + * Handles: + * - Automatic node ID generation based on existing nodes of the same type. + * - Loading of default data from the `NodeDefaults` registry. + * - Integration with the flow store to update global node state. + * + * @param nodeType - The type of node to create (from `NodeTypes`). + * @param position - The XY position in the flow canvas where the node will appear. */ function addNode(nodeType: keyof typeof NodeTypes, position: XYPosition) { const { nodes, setNodes } = useFlowStore.getState(); - // Find out if there's any default data about our ndoe + // Load any predefined data for this node type. const defaultData = NodeDefaults[nodeType] ?? {} - // Currently, we find out what the Id is by checking the last node and adding one + // Currently, we find out what the Id is by checking the last node and adding one. const sameTypeNodes = nodes.filter((node) => node.type === nodeType); const nextNumber = sameTypeNodes.length > 0 @@ -77,16 +94,28 @@ function addNode(nodeType: keyof typeof NodeTypes, position: XYPosition) { } /** - * DndToolbar defines how the drag and drop toolbar component works - * and includes the default onDrop behavior. + * The drag-and-drop toolbar component for the visual programming interface. + * + * Displays draggable node templates based on entries in `NodeDefaults`. + * Each droppable node can be dragged into the flow pane to instantiate it. + * + * Automatically filters nodes whose `droppable` flag is set to `true`. + * + * @returns A React element representing the drag-and-drop toolbar. */ export function DndToolbar() { const { screenToFlowPosition } = useReactFlow(); + /** + * Handles dropping a node onto the flow pane. + * Translates screen coordinates into flow coordinates using React Flow utilities. + */ const handleNodeDrop = useCallback( (nodeType: keyof typeof NodeTypes, screenPosition: XYPosition) => { const flow = document.querySelector('.react-flow'); const flowRect = flow?.getBoundingClientRect(); + + // Only add the node if it is inside the flow canvas area. const isInFlow = flowRect && screenPosition.x >= flowRect.left && @@ -103,7 +132,7 @@ export function DndToolbar() { ); - // Map over our default settings to see which of them have their droppable data set to true + // Map over the default nodes to get all nodes that can be dropped from the toolbar. const droppableNodes = Object.entries(NodeDefaults) .filter(([, data]) => data.droppable) .map(([type, data]) => ({ diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/NodeComponents.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/NodeComponents.tsx index 090fa38..460a508 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/NodeComponents.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/NodeComponents.tsx @@ -2,7 +2,12 @@ import { NodeToolbar } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; import useFlowStore from "../VisProgStores.tsx"; -//Toolbar definitions +/** + * Props for the Toolbar component. + * + * @property nodeId - The ID of the node this toolbar is attached to. + * @property allowDelete - If `true`, the delete button is enabled; otherwise disabled. + */ type ToolbarProps = { nodeId: string; allowDelete: boolean; @@ -10,12 +15,12 @@ type ToolbarProps = { /** * Node Toolbar definition: - * handles: node deleting functionality - * can be added to any custom node component as a React component + * Handles: node deleting functionality + * Can be integrated to any custom node component as a React component * - * @param {string} nodeId - * @param {boolean} allowDelete - * @returns {React.JSX.Element} + * @param {string} nodeId - The ID of the node for which the toolbar is rendered. + * @param {boolean} allowDelete - Enables or disables the delete functionality. + * @returns {React.JSX.Element} A JSX element representing the toolbar. * @constructor */ export function Toolbar({nodeId, allowDelete}: ToolbarProps) { diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx index 8cfa122..5be666b 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx @@ -93,6 +93,12 @@ export function GoalReduce(node: Node, nodes: Node[]) { } } +/** + * This function is called whenever a connection is made with this node type (Goal) + * @param thisNode the node of this node type which function is called + * @param otherNode the other node which was part of the connection + * @param isThisSource whether this instance of the node was the source in the connection, true = yes. + */ export function GoalConnects(thisNode: Node, otherNode: Node, isThisSource: boolean) { // Replace this for connection logic if (thisNode == undefined && otherNode == undefined && isThisSource == false) { diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx index 5789cac..d2ca50d 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx @@ -76,6 +76,12 @@ export function NormReduce(node: Node, nodes: Node[]) { } } +/** + * This function is called whenever a connection is made with this node type (Norm) + * @param thisNode the node of this node type which function is called + * @param otherNode the other node which was part of the connection + * @param isThisSource whether this instance of the node was the source in the connection, true = yes. + */ export function NormConnects(thisNode: Node, otherNode: Node, isThisSource: boolean) { // Replace this for connection logic if (thisNode == undefined && otherNode == undefined && isThisSource == false) { diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx index a6f114e..9c09c6e 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx @@ -14,10 +14,16 @@ import { RealtimeTextField, TextField } from '../../../../components/TextField'; import duplicateIndices from '../../../../utils/duplicateIndices'; /** - * The default data dot a Trigger node - * @param label: the label of this Trigger - * @param droppable: whether this node is droppable from the drop bar (initialized as true) - * @param children: ID's of children of this node + * The default data structure for a Trigger node + * + * Represents configuration for a node that activates when a specific condition is met, + * such as keywords being spoken or emotions detected. + * + * @property label: the display label of this Trigger node. + * @property droppable: Whether this node can be dropped from the toolbar (default: true). + * @property triggerType - The type of trigger ("keywords" or a custom string). + * @property triggers - The list of keyword triggers (if applicable). + * @property hasReduce - Whether this node supports reduction logic. */ export type TriggerNodeData = { label: string; @@ -31,14 +37,20 @@ export type TriggerNodeData = { export type TriggerNode = Node +/** + * Determines whether a Trigger node can connect to another node or edge. + * + * @param connection - The connection or edge being attempted to connect towards. + * @returns `true` if the connection is defined; otherwise, `false`. + */ export function TriggerNodeCanConnect(connection: Connection | Edge): boolean { return (connection != undefined); } /** * Defines how a Trigger node should be rendered - * @param props NodeProps, like id, label, children - * @returns React.JSX.Element + * @param props - Node properties provided by React Flow, including `id` and `data`. + * @returns The rendered TriggerNode React element (React.JSX.Element). */ export default function TriggerNode(props: NodeProps) { const data = props.data; @@ -66,9 +78,10 @@ export default function TriggerNode(props: NodeProps) { } /** - * Reduces each Trigger, including its children down into its relevant data. - * @param node: The Node Properties of this node. - * @param nodes: all the nodes in the graph. + * Reduces each Trigger, including its children down into its core data. + * @param node - The Trigger node to reduce. + * @param nodes - The list of all nodes in the current flow graph. + * @returns A simplified object containing the node label and its list of triggers. */ export function TriggerReduce(node: Node, nodes: Node[]) { // Replace this for nodes functionality @@ -83,10 +96,11 @@ export function TriggerReduce(node: Node, nodes: Node[]) { } /** - * This function is called whenever a connection is made with this node type (trigger) - * @param thisNode the node of this node type which function is called - * @param otherNode the other node which was part of the connection - * @param isThisSource whether this instance of the node was the source in the connection, true = yes. + * Handles logic that occurs when a connection is made involving a Trigger node. + * + * @param thisNode - The current Trigger node being connected. + * @param otherNode - The other node involved in the connection. + * @param isThisSource - Whether this node was the source of the connection. */ export function TriggerConnects(thisNode: Node, otherNode: Node, isThisSource: boolean) { // Replace this for connection logic @@ -96,23 +110,33 @@ export function TriggerConnects(thisNode: Node, otherNode: Node, isThisSource: b } // Definitions for the possible triggers, being keywords and emotions + +/** Represents a single keyword trigger entry. */ type Keyword = { id: string, keyword: string }; +/** Properties for an emotion-type trigger node. */ export type EmotionTriggerNodeProps = { type: "emotion"; value: string; } + +/** Props for a keyword-type trigger node. */ export type KeywordTriggerNodeProps = { type: "keywords"; value: Keyword[]; } +/** Union type for all possible Trigger node configurations. */ export type TriggerNodeProps = EmotionTriggerNodeProps | KeywordTriggerNodeProps; + /** - * The JSX element that is responsible for updating the field and showing the text - * @param param0 the function that updates the field - * @returns React.JSX.Element that handles adding keywords + * Renders an input element that allows users to add new keyword triggers. + * + * When the input is committed, the `addKeyword` callback is called with the new keyword. + * + * @param param0 - An object containing the `addKeyword` function. + * @returns A React element(React.JSX.Element) providing an input for adding keywords. */ function KeywordAdder({ addKeyword }: { addKeyword: (keyword: string) => void }) { const [input, setInput] = useState(""); @@ -136,6 +160,14 @@ function KeywordAdder({ addKeyword }: { addKeyword: (keyword: string) => void })
    ; } +/** + * Displays and manages a list of keyword triggers for a Trigger node. + * Handles adding, editing, and removing keywords, as well as detecting duplicate entries. + * + * @param keywords - The current list of keyword triggers. + * @param setKeywords - A callback to update the keyword list in the parent node. + * @returns A React element(React.JSX.Element) for editing keyword triggers. + */ function Keywords({ keywords, setKeywords, diff --git a/src/utils/cellStore.ts b/src/utils/cellStore.ts index eb64907..9c14695 100644 --- a/src/utils/cellStore.ts +++ b/src/utils/cellStore.ts @@ -2,12 +2,56 @@ import {useSyncExternalStore} from "react"; type Unsub = () => void; + +/** + * A simple reactive state container that holds a value of type `T` that provides methods to get, set, and subscribe. + */ export type Cell = { + /** + * Returns the current value stored in the cell. + */ get: () => T; + /** + * Updates the cell's value, pass either a direct value or an updater function. + * + * @example + * ```ts + * count.set(5); + * count.set(prev => prev + 1); + * ``` + */ set: (next: T | ((prev: T) => T)) => void; + + /** + * Subscribe to changes in the cell's value, meaning the provided callback is called whenever the value changes. + * Returns an unsubscribe function. + * + * @example + * ```ts + * const unsubscribe = count.subscribe(() => console.log(count.get())); + * // later: + * unsubscribe(); + * ``` + */ subscribe: (callback: () => void) => Unsub; }; +/** + * Creates a new reactive state container (`Cell`) with an initial value. + * + * This function allows you to store and mutate state outside of React, + * while still supporting subscriptions for reactivity. + * + * @param initial - The initial value for the cell. + * @returns A Cell object with `get`, `set`, and `subscribe` methods. + * + * @example + * ```ts + * const count = cell(0); + * count.set(10); + * console.log(count.get()); // 10 + * ``` + */ export function cell(initial: T): Cell { let value = initial; const listeners = new Set<() => void>(); @@ -24,6 +68,29 @@ export function cell(initial: T): Cell { }; } +/** + * React hook that subscribes a component to a Cell. + * + * Automatically re-renders the component whenever the Cell's value changes. + * Uses React’s built-in `useSyncExternalStore` for correct subscription behavior. + * + * @param c - The cell to subscribe to. + * @returns The current value of the cell. + * + * @example + * ```tsx + * const count = cell(0); + * + * function Counter() { + * const value = useCell(count); + * return ( + * + * ); + * } + * ``` + */ export function useCell(c: Cell) { return useSyncExternalStore(c.subscribe, c.get, c.get); } -- 2.49.1 From 381fdaca1ac91cfb39e6c7fd4a49f93cbf7ca8f8 Mon Sep 17 00:00:00 2001 From: Twirre Meulenbelt <43213592+TwirreM@users.noreply.github.com> Date: Thu, 27 Nov 2025 10:58:09 +0100 Subject: [PATCH 116/184] fix: re-render TextField when input changes from parent ref: N25B-189 --- src/components/TextField.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/TextField.tsx b/src/components/TextField.tsx index 58de55d..1d5ca6a 100644 --- a/src/components/TextField.tsx +++ b/src/components/TextField.tsx @@ -1,4 +1,4 @@ -import {useState} from "react"; +import {useEffect, useState} from "react"; import styles from "./TextField.module.css"; /** @@ -86,6 +86,9 @@ export function TextField({ }) { const [inputValue, setInputValue] = useState(value); + // Re-render when the value gets updated externally + useEffect(() => setInputValue(value), [setInputValue, value]); + const onCommit = () => setValue(inputValue); return Date: Thu, 27 Nov 2025 17:14:19 +0100 Subject: [PATCH 117/184] chore: create new tests for the UI, namely normnode, and one for all nodes --- .../components/DragDropSidebar.tsx | 7 +- test/components/Logging/Logging.test.tsx | 12 +- .../components/DragDropSidebar.test.tsx | 115 ++- .../nodes/NormNode.test.tsx | 745 ++++++++++++++++++ .../nodes/UniversalNodes.test.tsx | 55 ++ test/test-utils/mocks.ts | 41 + test/test-utils/test-utils.tsx | 35 + 7 files changed, 999 insertions(+), 11 deletions(-) create mode 100644 test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx create mode 100644 test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx create mode 100644 test/test-utils/mocks.ts create mode 100644 test/test-utils/test-utils.tsx diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx index 97b563b..fb4857e 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx @@ -37,7 +37,11 @@ function DraggableNode({ className, children, nodeType, onDrop }: DraggableNodeP }); return ( -
    +
    {children}
    ); @@ -120,6 +124,7 @@ export function DndToolbar() { {/* Maps over all the nodes that are droppable, and puts them in the panel */} {droppableNodes.map(({type, data}) => ( { render(); - expect(screen.getByText("Logs")).toBeInTheDocument(); - expect(screen.getByText("WARNING")).toBeInTheDocument(); - expect(screen.getByText("logging")).toBeInTheDocument(); - expect(screen.getByText("Ping")).toBeInTheDocument(); + expect(screen.getByText("Logs")).toBeDefined(); + expect(screen.getByText("WARNING")).toBeDefined(); + expect(screen.getByText("logging")).toBeDefined(); + expect(screen.getByText("Ping")).toBeDefined(); let timestamp = screen.queryByText("ABS TIME"); if (!timestamp) { @@ -141,7 +141,7 @@ describe("Logging component", () => { } await user.click(timestamp); - expect(screen.getByText("00:00:12.345")).toBeInTheDocument(); + expect(screen.getByText("00:00:12.345")).toBeDefined(); }); it("shows the scroll-to-bottom button after a manual scroll and scrolls when clicked", async () => { @@ -188,7 +188,7 @@ describe("Logging component", () => { logCell.set({...current, message: "Updated"}); }); - expect(screen.getByText("Updated")).toBeInTheDocument(); + expect(screen.getByText("Updated")).toBeDefined(); await waitFor(() => { expect(scrollSpy).toHaveBeenCalledTimes(1); }); diff --git a/test/pages/visProgPage/visualProgrammingUI/components/DragDropSidebar.test.tsx b/test/pages/visProgPage/visualProgrammingUI/components/DragDropSidebar.test.tsx index 70087ee..a17fde8 100644 --- a/test/pages/visProgPage/visualProgrammingUI/components/DragDropSidebar.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/components/DragDropSidebar.test.tsx @@ -1,5 +1,112 @@ -describe('Not implemented', () => { - test('nothing yet', () => { - expect(true) - }); +import { getByTestId, render } from '@testing-library/react'; +import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores'; +import VisProgPage from '../../../../../src/pages/VisProgPage/VisProg'; +import userEvent from '@testing-library/user-event'; + + + +class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} +window.ResizeObserver = ResizeObserver; + +jest.mock('@neodrag/react', () => ({ + useDraggable: (ref: React.RefObject, options: any) => { + // We access the real useEffect from React to attach a listener + // This bridges the gap between the test's userEvent and the component's logic + const { useEffect } = jest.requireActual('react'); + + useEffect(() => { + const element = ref.current; + if (!element) return; + + // When the test fires a "pointerup" (end of click/drag), + // we manually trigger the library's onDragEnd callback. + const handlePointerUp = (e: PointerEvent) => { + if (options.onDragEnd) { + options.onDragEnd({ event: e }); + } + }; + + element.addEventListener('pointerup', handlePointerUp as EventListener); + return () => { + element.removeEventListener('pointerup', handlePointerUp as EventListener); + }; + }, [ref, options]); + }, +})); + +// We will mock @xyflow/react so we control screenToFlowPosition +jest.mock('@xyflow/react', () => { + const actual = jest.requireActual('@xyflow/react'); + return { + ...actual, + useReactFlow: () => ({ + screenToFlowPosition: ({ x, y }: { x: number; y: number }) => ({ + x: x - 100, + y: y - 100, + }), + }), + }; }); + +// Reset Zustand state helper +function resetStore() { + useFlowStore.setState({ nodes: [], edges: [] }); +} + +describe("Drag & drop node creation", () => { + beforeEach(() => resetStore()); + + test("drops a phase node inside the canvas and adds it with transformed position", async () => { + const user = userEvent.setup(); + + const { container } = render(); + + // --- Mock ReactFlow bounding box --- + // Your DndToolbar checks these values: + const flowEl = container.querySelector('.react-flow'); + jest.spyOn(flowEl!, 'getBoundingClientRect').mockReturnValue({ + left: 0, + right: 800, + top: 0, + bottom: 600, + width: 800, + height: 600, + x: 0, + y: 0, + toJSON: () => {}, + }); + + + const phaseLabel = getByTestId(container, 'draggable-phase') + + await user.pointer([ + // touch the screen at element1 + {keys: '[TouchA>]', target: phaseLabel}, + // move the touch pointer to element2 + {pointerName: 'TouchA', coords: {x: 300, y: 250}}, + // release the touch pointer at the last position (element2) + {keys: '[/TouchA]'}, + ]); + + // Read the Zustand store + const { nodes } = useFlowStore.getState(); + + // --- Assertions --- + expect(nodes.length).toBe(1); + + const node = nodes[0]; + + expect(node.type).toBe("phase"); + expect(node.id).toBe("phase-1"); + + // screenToFlowPosition was mocked to subtract 100 + expect(node.position).toEqual({ + x: 200, + y: 150, + }); + }); +}); \ No newline at end of file diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx new file mode 100644 index 0000000..9e3d049 --- /dev/null +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx @@ -0,0 +1,745 @@ +import { describe, it, beforeEach } from '@jest/globals'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWithProviders, resetFlowStore } from '../.././/./../../test-utils/test-utils'; +import NormNode, { NormReduce, NormConnects, type NormNodeData } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode' +import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores'; +import type { Node } from '@xyflow/react'; +import '@testing-library/jest-dom' + + + +describe('NormNode', () => { + let user: ReturnType; + + beforeEach(() => { + resetFlowStore(); + user = userEvent.setup(); + }); + + describe('Rendering', () => { + it('should render the norm node with default data', () => { + const mockNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Test Norm', + droppable: true, + norm: '', + hasReduce: true, + }, + }; + + renderWithProviders( + + ); + + expect(screen.getByPlaceholderText('Pepper should ...')).toBeInTheDocument(); + }); + + it('should render with pre-populated norm text', () => { + const mockNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Test Norm', + droppable: true, + norm: 'Be respectful to humans', + hasReduce: true, + }, + }; + + renderWithProviders( + + ); + + const input = screen.getByDisplayValue('Be respectful to humans'); + expect(input).toBeInTheDocument(); + }); + + it('should render with selected state', () => { + const mockNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Test Norm', + droppable: true, + norm: '', + hasReduce: true, + }, + }; + + renderWithProviders( + + ); + + let norm = screen.getByText("Norm :") + expect(norm).toBeInTheDocument; + }); + + it('should render with dragging state', () => { + const mockNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Test Norm', + droppable: true, + norm: 'Dragged norm', + hasReduce: true, + }, + }; + + renderWithProviders( + + ); + + const input = screen.getByDisplayValue('Dragged norm'); + expect(input).toBeInTheDocument(); + }); + }); + + describe('User Interactions', () => { + it('should update norm text when user types in the input field', async () => { + const mockNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Test Norm', + droppable: true, + norm: '', + hasReduce: true, + }, + }; + + useFlowStore.setState({ + nodes: [mockNode], + edges: [], + }); + + renderWithProviders( + + ); + + const input = screen.getByPlaceholderText('Pepper should ...'); + await user.type(input, 'Be polite to guests{enter}'); + + await waitFor(() => { + const state = useFlowStore.getState(); + const updatedNode = state.nodes.find(n => n.id === 'norm-1'); + expect(updatedNode?.data.norm).toBe('Be polite to guests'); + }); + }); + + it('should handle clearing the norm text', async () => { + const mockNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Test Norm', + droppable: true, + norm: 'Initial norm text', + hasReduce: true, + }, + }; + + useFlowStore.setState({ + nodes: [mockNode], + edges: [], + }); + + renderWithProviders( + + ); + + const input = screen.getByDisplayValue('Initial norm text') as HTMLInputElement; + + // clearing the norm text is the same as just deleting all characters one by one + // TODO: FIGURE OUT A MORE EFFICIENT WAY, I"M SORRY, user.clear() just doesn't work:/ + for (let a = 0; a < 'Initial norm text'.length; a++){ + await user.type(input, '{backspace}') + } + await user.type(input,'{enter}') + + await waitFor(() => { + const state = useFlowStore.getState(); + const updatedNode = state.nodes.find(n => n.id === 'norm-1'); + expect(updatedNode?.data.norm).toBe(''); + }); + }); + + it('should update norm text multiple times', async () => { + const mockNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Test Norm', + droppable: true, + norm: '', + hasReduce: true, + }, + }; + + useFlowStore.setState({ + nodes: [mockNode], + edges: [], + }); + + renderWithProviders( + + ); + + const input = screen.getByPlaceholderText('Pepper should ...'); + await user.type(input, 'First norm{enter}'); + await waitFor(() => { + expect(useFlowStore.getState().nodes[0].data.norm).toBe('First norm'); + }); + + + // TODO: FIGURE OUT A MORE EFFICIENT WAY, I"M SORRY, user.clear() just doesn't work:/ + for (let a = 0; a < 'First norm'.length; a++){ + await user.type(input, '{backspace}') + } + + await user.type(input, 'Second norm{enter}'); + await waitFor(() => { + expect(useFlowStore.getState().nodes[0].data.norm).toBe('Second norm'); + }); + }); + + it('should handle special characters in norm text', async () => { + const mockNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Test Norm', + droppable: true, + norm: '', + hasReduce: true, + }, + }; + + useFlowStore.setState({ + nodes: [mockNode], + edges: [], + }); + + renderWithProviders( + + ); + + const input = screen.getByPlaceholderText('Pepper should ...'); + await user.type(input, "Don't harm & be nice!{enter}" ); + + await waitFor(() => { + expect(useFlowStore.getState().nodes[0].data.norm).toBe("Don't harm & be nice!"); + }); + }); + + it('should handle long norm text', async () => { + const longText = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.'; + const mockNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Test Norm', + droppable: true, + norm: '', + hasReduce: true, + }, + }; + + useFlowStore.setState({ + nodes: [mockNode], + edges: [], + }); + + renderWithProviders( + + ); + + const input = screen.getByPlaceholderText('Pepper should ...'); + await user.type(input, longText); + await user.type(input, "{enter}") + + await waitFor(() => { + expect(useFlowStore.getState().nodes[0].data.norm).toBe(longText); + }); + }); + }); + + describe('NormReduce Function', () => { + it('should reduce a norm node to its essential data', () => { + const normNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Safety Norm', + droppable: true, + norm: 'Never harm humans', + hasReduce: true, + }, + }; + + const allNodes: Node[] = [normNode]; + const result = NormReduce(normNode, allNodes); + + expect(result).toEqual({ + id: 'norm-1', + label: 'Safety Norm', + norm: 'Never harm humans', + }); + }); + + it('should reduce multiple norm nodes independently', () => { + const norm1: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Norm 1', + droppable: true, + norm: 'Be helpful', + hasReduce: true, + }, + }; + + const norm2: Node = { + id: 'norm-2', + type: 'norm', + position: { x: 100, y: 0 }, + data: { + label: 'Norm 2', + droppable: true, + norm: 'Be honest', + hasReduce: true, + }, + }; + + const allNodes: Node[] = [norm1, norm2]; + + const result1 = NormReduce(norm1, allNodes); + const result2 = NormReduce(norm2, allNodes); + + expect(result1.id).toBe('norm-1'); + expect(result1.norm).toBe('Be helpful'); + expect(result2.id).toBe('norm-2'); + expect(result2.norm).toBe('Be honest'); + }); + + it('should handle empty norm text', () => { + const normNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Empty Norm', + droppable: true, + norm: '', + hasReduce: true, + }, + }; + + const result = NormReduce(normNode, [normNode]); + + expect(result.norm).toBe(''); + expect(result.id).toBe('norm-1'); + }); + + it('should preserve node label in reduction', () => { + const normNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Custom Label', + droppable: false, + norm: 'Test norm', + hasReduce: false, + }, + }; + + const result = NormReduce(normNode, [normNode]); + + expect(result.label).toBe('Custom Label'); + }); + }); + + describe('NormConnects Function', () => { + it('should handle connection without errors', () => { + const normNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Test Norm', + droppable: true, + norm: 'Test', + hasReduce: true, + }, + }; + + const phaseNode: Node = { + id: 'phase-1', + type: 'phase', + position: { x: 100, y: 0 }, + data: { + label: 'Phase 1', + droppable: true, + children: [], + hasReduce: true, + }, + }; + + expect(() => { + NormConnects(normNode, phaseNode, true); + }).not.toThrow(); + }); + + it('should handle connection when norm is target', () => { + const normNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Test Norm', + droppable: true, + norm: 'Test', + hasReduce: true, + }, + }; + + const phaseNode: Node = { + id: 'phase-1', + type: 'phase', + position: { x: 100, y: 0 }, + data: { + label: 'Phase 1', + droppable: true, + children: [], + hasReduce: true, + }, + }; + + expect(() => { + NormConnects(normNode, phaseNode, false); + }).not.toThrow(); + }); + + it('should handle self-connection', () => { + const normNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Test Norm', + droppable: true, + norm: 'Test', + hasReduce: true, + }, + }; + + expect(() => { + NormConnects(normNode, normNode, true); + }).not.toThrow(); + }); + }); + + describe('Integration with Store', () => { + it('should properly update the store when editing norm text', async () => { + const mockNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Test Norm', + droppable: true, + norm: '', + hasReduce: true, + }, + }; + + useFlowStore.setState({ + nodes: [mockNode], + edges: [], + }); + + renderWithProviders( + + ); + + const input = screen.getByPlaceholderText('Pepper should ...'); + + // TODO: FIGURE OUT A MORE EFFICIENT WAY, I"M SORRY, user.clear() just doesn't work:/ + for (let a = 0; a < 20; a++){ + await user.type(input, '{backspace}') + } + await user.type(input, 'New norm value{enter}'); + + await waitFor(() => { + const state = useFlowStore.getState(); + expect(state.nodes).toHaveLength(1); + expect(state.nodes[0].id).toBe('norm-1'); + expect(state.nodes[0].data.norm).toBe('New norm value'); + }); + }); + + it('should not affect other nodes when updating one norm node', async () => { + const norm1: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Norm 1', + droppable: true, + norm: 'Original norm 1', + hasReduce: true, + }, + }; + + const norm2: Node = { + id: 'norm-2', + type: 'norm', + position: { x: 100, y: 0 }, + data: { + label: 'Norm 2', + droppable: true, + norm: 'Original norm 2', + hasReduce: true, + }, + }; + + useFlowStore.setState({ + nodes: [norm1, norm2], + edges: [], + }); + + renderWithProviders( + + ); + + const input = screen.getByDisplayValue('Original norm 1') as HTMLInputElement; + + + // TODO: FIGURE OUT A MORE EFFICIENT WAY, I"M SORRY, user.clear() just doesn't work:/ + for (let a = 0; a < 20; a++){ + await user.type(input, '{backspace}') + } + await user.type(input, 'Updated norm 1{enter}'); + + await waitFor(() => { + const state = useFlowStore.getState(); + const updatedNorm1 = state.nodes.find(n => n.id === 'norm-1'); + const unchangedNorm2 = state.nodes.find(n => n.id === 'norm-2'); + + expect(updatedNorm1?.data.norm).toBe('Updated norm 1'); + expect(unchangedNorm2?.data.norm).toBe('Original norm 2'); + }); + }); + + it('should maintain data consistency with multiple rapid updates', async () => { + const mockNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Test Norm', + droppable: true, + norm: 'haa haa fuyaaah - link', + hasReduce: true, + }, + }; + + useFlowStore.setState({ + nodes: [mockNode], + edges: [], + }); + + renderWithProviders( + + ); + + const input = screen.getByPlaceholderText('Pepper should ...'); + + await user.type(input, 'a'); + await waitFor(() => { + expect(useFlowStore.getState().nodes[0].data.norm).toBe('haa haa fuyaaah - link'); + }); + + await user.type(input, 'b'); + await waitFor(() => { + expect(useFlowStore.getState().nodes[0].data.norm).toBe('haa haa fuyaaah - link'); + }); + + await user.type(input, 'c'); + await waitFor(() => { + expect(useFlowStore.getState().nodes[0].data.norm).toBe('haa haa fuyaaah - link'); + }, { timeout: 3000 }); + }); + }); +}); \ No newline at end of file diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx new file mode 100644 index 0000000..7e1e9ca --- /dev/null +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx @@ -0,0 +1,55 @@ +import { describe, beforeEach } from '@jest/globals'; +import { screen } from '@testing-library/react'; +import { renderWithProviders, resetFlowStore } from '../.././/./../../test-utils/test-utils'; +import type { XYPosition } from '@xyflow/react'; +import { NodeTypes, NodeDefaults } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/NodeRegistry'; +import '@testing-library/jest-dom' + + +describe('NormNode', () => { + // let user: ReturnType; + + // Copied from VisStores. + function createNode(id: string, type: string, position: XYPosition, data: Record, deletable? : boolean) { + const defaultData = NodeDefaults[type as keyof typeof NodeDefaults] + const newData = { + id: id, + type: type, + position: position, + data: data, + deletable: deletable, + } + return {...defaultData, ...newData} + } + + + beforeEach(() => { + resetFlowStore(); + // user = userEvent.setup(); + }); + + describe('Rendering', () => { + test.each([Object.entries(NodeTypes)].map(([t])=>t))('it should render each node with the default data', (nodeType) => { + let newNode = createNode(nodeType + "1", nodeType, {x: 200, y:200}, {}) + let uiElement = Object.entries(NodeTypes).find(([t])=>t==nodeType)?.[1]!; + let props = { + id:newNode.id, + type:newNode.type as string, + data:newNode.data as any, + selected:false, + isConnectable:true, + zIndex:0, + dragging:false, + selectable:true, + deletable:true, + draggable:true, + positionAbsoluteX:0, + positionAbsoluteY:0,} + renderWithProviders(uiElement(props)); + const elements = screen.queryAllByText((content, ) => + content.toLowerCase().includes(nodeType.toLowerCase()) + ); + expect(elements.length).toBeGreaterThan(0); + }); + }); +}); \ No newline at end of file diff --git a/test/test-utils/mocks.ts b/test/test-utils/mocks.ts new file mode 100644 index 0000000..21971c1 --- /dev/null +++ b/test/test-utils/mocks.ts @@ -0,0 +1,41 @@ +import { jest } from '@jest/globals'; +import React from 'react'; +import '@testing-library/jest-dom'; + +/** + * Mock for @xyflow/react + * Provides simplified versions of React Flow hooks and components + */ +jest.mock('@xyflow/react', () => ({ + useReactFlow: jest.fn(() => ({ + screenToFlowPosition: jest.fn((pos: any) => pos), + getNode: jest.fn(), + getNodes: jest.fn(() => []), + getEdges: jest.fn(() => []), + setNodes: jest.fn(), + setEdges: jest.fn(), + })), + ReactFlowProvider: ({ children }: { children: React.ReactNode }) => + React.createElement('div', { 'data-testid': 'react-flow-provider' }, children), + ReactFlow: ({ children, ...props }: any) => + React.createElement('div', { 'data-testid': 'react-flow', ...props }, children), + Handle: ({ type, position, id }: any) => + React.createElement('div', { 'data-testid': `handle-${type}-${id}`, 'data-position': position }), + Panel: ({ children, position }: any) => + React.createElement('div', { 'data-testid': 'panel', 'data-position': position }, children), + Controls: () => React.createElement('div', { 'data-testid': 'controls' }), + Background: () => React.createElement('div', { 'data-testid': 'background' }), +})); + +/** + * Mock for @neodrag/react + * Simplifies drag behavior for testing + */ +jest.mock('@neodrag/react', () => ({ + useDraggable: jest.fn((ref: any, options?: any) => { + // Store the options so we can trigger them in tests + if (ref && ref.current) { + (ref.current as any)._dragOptions = options; + } + }), +})); \ No newline at end of file diff --git a/test/test-utils/test-utils.tsx b/test/test-utils/test-utils.tsx new file mode 100644 index 0000000..76878b9 --- /dev/null +++ b/test/test-utils/test-utils.tsx @@ -0,0 +1,35 @@ +// __tests__/utils/test-utils.tsx +import { render, type RenderOptions } from '@testing-library/react'; +import { type ReactElement, type ReactNode } from 'react'; +import { ReactFlowProvider } from '@xyflow/react'; +import useFlowStore from '../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores'; + +/** + * Custom render function that wraps components with necessary providers + * This ensures all components have access to ReactFlow context + */ +export function renderWithProviders( + ui: ReactElement, + options?: Omit +) { + function Wrapper({ children }: { children: ReactNode }) { + return {children}; + } + + return render(ui, { wrapper: Wrapper, ...options }); +} + +/** + * Helper to reset the Zustand store between tests + * This ensures test isolation + */ +export function resetFlowStore() { + useFlowStore.setState({ + nodes: [], + edges: [], + edgeReconnectSuccessful: true, + }); +} + +// Re-export everything from testing library +export * from '@testing-library/react'; \ No newline at end of file -- 2.49.1 From 2261da99156ba9a60802b628ae5c7aa9571c54b3 Mon Sep 17 00:00:00 2001 From: JobvAlewijk Date: Thu, 27 Nov 2025 18:45:11 +0100 Subject: [PATCH 118/184] test: robot, and 2 nodes tests added. ref: N25B-292 --- test/pages/robot/Robot.test.tsx | 167 ++++++++++++ .../nodes/StartNode.test.tsx | 100 +++++++ .../nodes/TriggerNode.test.tsx | 244 ++++++++++++++++++ 3 files changed, 511 insertions(+) create mode 100644 test/pages/robot/Robot.test.tsx create mode 100644 test/pages/visProgPage/visualProgrammingUI/nodes/StartNode.test.tsx create mode 100644 test/pages/visProgPage/visualProgrammingUI/nodes/TriggerNode.test.tsx diff --git a/test/pages/robot/Robot.test.tsx b/test/pages/robot/Robot.test.tsx new file mode 100644 index 0000000..bcebac8 --- /dev/null +++ b/test/pages/robot/Robot.test.tsx @@ -0,0 +1,167 @@ +import { render, screen, act, cleanup, fireEvent } from '@testing-library/react'; +import Robot from '../../../src/pages/Robot/Robot'; + +// Mock EventSource +const mockInstances: MockEventSource[] = []; +class MockEventSource { + url: string; + onmessage: ((event: MessageEvent) => void) | null = null; + closed = false; + + constructor(url: string) { + this.url = url; + mockInstances.push(this); + } + + sendMessage(data: string) { + this.onmessage?.({ data } as MessageEvent); + } + + close() { + this.closed = true; + } +} + +// Mock global EventSource +beforeAll(() => { + (globalThis as any).EventSource = jest.fn((url: string) => new MockEventSource(url)); +}); + +// Mock fetch +beforeEach(() => { + globalThis.fetch = jest.fn(() => + Promise.resolve({ + json: () => Promise.resolve({ reply: 'ok' }), + }) + ) as jest.Mock; +}); + +// Cleanup +afterEach(() => { + cleanup(); + jest.restoreAllMocks(); + mockInstances.length = 0; +}); + +describe('Robot', () => { + test('renders initial state', () => { + render(); + expect(screen.getByText('Robot interaction')).toBeInTheDocument(); + expect(screen.getByText('Force robot speech')).toBeInTheDocument(); + expect(screen.getByText('Listening 🔴')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Enter a message')).toBeInTheDocument(); + }); + + test('sends message via button', async () => { + render(); + const input = screen.getByPlaceholderText('Enter a message'); + const button = screen.getByText('Speak'); + + fireEvent.change(input, { target: { value: 'Hello' } }); + await act(async () => fireEvent.click(button)); + + expect(globalThis.fetch).toHaveBeenCalledWith( + 'http://localhost:8000/message', + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message: 'Hello' }), + }) + ); + }); + + test('sends message via Enter key', async () => { + render(); + const input = screen.getByPlaceholderText('Enter a message'); + fireEvent.change(input, { target: { value: 'Hi Enter' } }); + + await act(async () => + fireEvent.keyDown(input, { key: 'Enter', code: 'Enter', charCode: 13 }) + ); + + expect(globalThis.fetch).toHaveBeenCalledWith( + 'http://localhost:8000/message', + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message: 'Hi Enter' }), + }) + ); + expect((input as HTMLInputElement).value).toBe(''); + }); + + test('handles fetch errors', async () => { + globalThis.fetch = jest.fn(() => Promise.reject('Network error')) as jest.Mock; + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + render(); + const input = screen.getByPlaceholderText('Enter a message'); + const button = screen.getByText('Speak'); + fireEvent.change(input, { target: { value: 'Error test' } }); + + await act(async () => fireEvent.click(button)); + + expect(consoleSpy).toHaveBeenCalledWith( + 'Error sending message: ', + 'Network error' + ); + }); + + test('updates conversation on SSE', async () => { + render(); + const eventSource = mockInstances[0]; + + await act(async () => { + eventSource.sendMessage(JSON.stringify({ voice_active: true })); + eventSource.sendMessage(JSON.stringify({ speech: 'User says hi' })); + eventSource.sendMessage(JSON.stringify({ llm_response: 'Assistant replies' })); + }); + + expect(screen.getByText('Listening 🟢')).toBeInTheDocument(); + expect(screen.getByText('User says hi')).toBeInTheDocument(); + expect(screen.getByText('Assistant replies')).toBeInTheDocument(); + }); + + test('handles invalid SSE JSON', async () => { + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + render(); + const eventSource = mockInstances[0]; + + await act(async () => eventSource.sendMessage('bad-json')); + + expect(logSpy).toHaveBeenCalledWith('Unparsable SSE message:', 'bad-json'); + }); + + test('resets conversation with Reset button', async () => { + render(); + const eventSource = mockInstances[0]; + + await act(async () => + eventSource.sendMessage(JSON.stringify({ speech: 'Hello' })) + ); + expect(screen.getByText('Hello')).toBeInTheDocument(); + + fireEvent.click(screen.getByText('Reset')); + expect(screen.queryByText('Hello')).not.toBeInTheDocument(); + }); + + test('toggles conversationIndex with Stop/Start button', () => { + render(); + const stopButton = screen.getByText('Stop'); + fireEvent.click(stopButton); + expect(screen.getByText('Start')).toBeInTheDocument(); + + fireEvent.click(screen.getByText('Start')); + expect(screen.getByText('Stop')).toBeInTheDocument(); + }); + + test('closes EventSource on unmount', () => { + const { unmount } = render(); + const eventSource = mockInstances[0]; + const closeSpy = jest.spyOn(eventSource, 'close'); + + unmount(); + expect(closeSpy).toHaveBeenCalled(); + expect(eventSource.closed).toBe(true); + }); +}); diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/StartNode.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/StartNode.test.tsx new file mode 100644 index 0000000..3754202 --- /dev/null +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/StartNode.test.tsx @@ -0,0 +1,100 @@ +import { describe, it, beforeEach } from '@jest/globals'; +import { screen } from '@testing-library/react'; +import { renderWithProviders, resetFlowStore } from '../.././/./../../test-utils/test-utils'; +import StartNode, { StartReduce, StartConnects } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode'; +import type { Node } from '@xyflow/react'; +import '@testing-library/jest-dom'; + +describe('StartNode', () => { + + beforeEach(() => { + resetFlowStore(); + }); + + describe('Rendering', () => { + it('renders the StartNode correctly', () => { + const mockNode: Node = { + id: 'start-1', + type: 'start', // TypeScript now knows this is a string + position: { x: 0, y: 0 }, + data: { + label: 'Start Node', + droppable: false, + hasReduce: true, + }, + }; + + renderWithProviders( + + ); + + + expect(screen.getByText('Start')).toBeInTheDocument(); + + // The handle should exist in the DOM + expect(document.querySelector('[data-handleid="source"]')).toBeInTheDocument(); + + }); + }); + + describe('StartReduce Function', () => { + it('reduces the StartNode to its minimal structure', () => { + const mockNode: Node = { + id: 'start-1', + type: 'start', + position: { x: 0, y: 0 }, + data: { + label: 'Start Node', + droppable: false, + hasReduce: true, + }, + }; + + const result = StartReduce(mockNode, [mockNode]); + expect(result).toEqual({ id: 'start-1' }); + }); + }); + + describe('StartConnects Function', () => { + it('handles connections without throwing', () => { + const startNode: Node = { + id: 'start-1', + type: 'start', + position: { x: 0, y: 0 }, + data: { + label: 'Start Node', + droppable: false, + hasReduce: true, + }, + }; + + const otherNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 100, y: 0 }, + data: { + label: 'Norm Node', + droppable: true, + norm: 'test', + hasReduce: true, + }, + }; + + expect(() => StartConnects(startNode, otherNode, true)).not.toThrow(); + expect(() => StartConnects(startNode, otherNode, false)).not.toThrow(); + }); + }); +}); diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/TriggerNode.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/TriggerNode.test.tsx new file mode 100644 index 0000000..a7c5437 --- /dev/null +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/TriggerNode.test.tsx @@ -0,0 +1,244 @@ +import { describe, it, beforeEach } from '@jest/globals'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWithProviders, resetFlowStore } from '../.././/./../../test-utils/test-utils'; +import TriggerNode, { TriggerReduce, TriggerConnects, TriggerNodeCanConnect, type TriggerNodeData } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode'; +import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores'; +import type { Node } from '@xyflow/react'; +import '@testing-library/jest-dom'; + +describe('TriggerNode', () => { + let user: ReturnType; + + beforeEach(() => { + resetFlowStore(); + user = userEvent.setup(); + }); + + describe('Rendering', () => { + it('should render TriggerNode with keywords type', () => { + const mockNode: Node = { + id: 'trigger-1', + type: 'trigger', + position: { x: 0, y: 0 }, + data: { + label: 'Keyword Trigger', + droppable: true, + triggerType: 'keywords', + triggers: [], + hasReduce: true, + }, + }; + + renderWithProviders( + + ); + + expect(screen.getByText(/Triggers when the keyword is spoken/i)).toBeInTheDocument(); + expect(screen.getByPlaceholderText('...')).toBeInTheDocument(); + }); + + it('should render TriggerNode with emotion type', () => { + const mockNode: Node = { + id: 'trigger-2', + type: 'trigger', + position: { x: 0, y: 0 }, + data: { + label: 'Emotion Trigger', + droppable: true, + triggerType: 'emotion', + triggers: [], + hasReduce: true, + }, + }; + + renderWithProviders( + + ); + + expect(screen.getByText(/Emotion\?/i)).toBeInTheDocument(); + }); + }); + + describe('User Interactions', () => { + it('should add a new keyword', async () => { + const mockNode: Node = { + id: 'trigger-1', + type: 'trigger', + position: { x: 0, y: 0 }, + data: { + label: 'Keyword Trigger', + droppable: true, + triggerType: 'keywords', + triggers: [], + hasReduce: true, + }, + }; + + useFlowStore.setState({ nodes: [mockNode], edges: [] }); + + renderWithProviders( + + ); + + const input = screen.getByPlaceholderText('...'); + await user.type(input, 'hello{enter}'); + + await waitFor(() => { + const node = useFlowStore.getState().nodes.find(n => n.id === 'trigger-1') as Node | undefined; + expect(node?.data.triggers.length).toBe(1); + expect(node?.data.triggers[0].keyword).toBe('hello'); + }); + + }); + + it('should remove a keyword when cleared', async () => { + const mockNode: Node = { + id: 'trigger-1', + type: 'trigger', + position: { x: 0, y: 0 }, + data: { + label: 'Keyword Trigger', + droppable: true, + triggerType: 'keywords', + triggers: [{ id: 'kw1', keyword: 'hello' }], + hasReduce: true, + }, + }; + + useFlowStore.setState({ nodes: [mockNode], edges: [] }); + + renderWithProviders( + + ); + + const input = screen.getByDisplayValue('hello'); + for (let i = 0; i < 'hello'.length; i++) { + await user.type(input, '{backspace}'); + } + await user.type(input, '{enter}'); + + await waitFor(() => { + const node = useFlowStore.getState().nodes.find(n => n.id === 'trigger-1') as Node | undefined; + expect(node?.data.triggers.length).toBe(0); + }); + + }); + }); + + describe('TriggerReduce Function', () => { + it('should reduce a trigger node to its essential data', () => { + const triggerNode: Node = { + id: 'trigger-1', + type: 'trigger', + position: { x: 0, y: 0 }, + data: { + label: 'Keyword Trigger', + droppable: true, + triggerType: 'keywords', + triggers: [{ id: 'kw1', keyword: 'hello' }], + hasReduce: true, + }, + }; + + const allNodes: Node[] = [triggerNode]; + const result = TriggerReduce(triggerNode, allNodes); + + expect(result).toEqual({ + label: 'Keyword Trigger', + list: [{ id: 'kw1', keyword: 'hello' }], + }); + }); + }); + + describe('TriggerConnects Function', () => { + it('should handle connection without errors', () => { + const node1: Node = { + id: 'trigger-1', + type: 'trigger', + position: { x: 0, y: 0 }, + data: { + label: 'Trigger 1', + droppable: true, + triggerType: 'keywords', + triggers: [], + hasReduce: true, + }, + }; + + const node2: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 100, y: 0 }, + data: { + label: 'Norm 1', + droppable: true, + norm: 'test', + hasReduce: true, + }, + }; + + expect(() => { + TriggerConnects(node1, node2, true); + TriggerConnects(node1, node2, false); + }).not.toThrow(); + }); + + it('should return true for TriggerNodeCanConnect if connection exists', () => { + const connection = { source: 'trigger-1', target: 'norm-1' }; + expect(TriggerNodeCanConnect(connection as any)).toBe(true); + }); + }); +}); -- 2.49.1 From d4393e76354ced93763d986cb8cd05781d291904 Mon Sep 17 00:00:00 2001 From: JobvAlewijk Date: Tue, 2 Dec 2025 11:36:10 +0100 Subject: [PATCH 119/184] test: scroll ref: N25B-292 --- .../components/ScrollIntoView.test.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 test/pages/visProgPage/visualProgrammingUI/components/ScrollIntoView.test.tsx diff --git a/test/pages/visProgPage/visualProgrammingUI/components/ScrollIntoView.test.tsx b/test/pages/visProgPage/visualProgrammingUI/components/ScrollIntoView.test.tsx new file mode 100644 index 0000000..2a91e85 --- /dev/null +++ b/test/pages/visProgPage/visualProgrammingUI/components/ScrollIntoView.test.tsx @@ -0,0 +1,14 @@ +import { render } from '@testing-library/react'; +import { act } from '@testing-library/react'; +import ScrollIntoView from '../../../../../src/components/ScrollIntoView'; + +test('scrolls the element into view on render', () => { + const scrollMock = jest.fn(); + HTMLElement.prototype.scrollIntoView = scrollMock; + + act(() => { + render(); + }); + + expect(scrollMock).toHaveBeenCalledWith({ behavior: 'smooth' }); +}); -- 2.49.1 From a95fbd15e6287651f7af945e4aa196ef16709f81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Tue, 2 Dec 2025 12:01:23 +0100 Subject: [PATCH 120/184] test: create universal tests and rewrite nodes to have optional parameters for more code coverage ref: N25B-362 --- .../visualProgrammingUI/NodeRegistry.ts | 4 +- .../visualProgrammingUI/nodes/EndNode.tsx | 19 +-- .../visualProgrammingUI/nodes/GoalNode.tsx | 13 +- .../visualProgrammingUI/nodes/NormNode.tsx | 14 +- .../visualProgrammingUI/nodes/PhaseNode.tsx | 6 +- .../visualProgrammingUI/nodes/StartNode.tsx | 19 +-- .../visualProgrammingUI/nodes/TriggerNode.tsx | 21 +-- .../nodes/UniversalNodes.test.tsx | 151 ++++++++++++++---- 8 files changed, 153 insertions(+), 94 deletions(-) diff --git a/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts b/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts index ca8ef73..3d18467 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts @@ -59,7 +59,7 @@ export const NodeConnects = { phase: PhaseConnects, norm: NormConnects, goal: GoalConnects, - trigger: TriggerConnects, + trigger: TriggerConnects, } /** @@ -69,6 +69,7 @@ export const NodeConnects = { export const NodeDeletes = { start: () => false, end: () => false, + test: () => false, // Used for coverage of universal/ undefined nodes } /** @@ -79,4 +80,5 @@ export const NodesInPhase = { start: () => false, end: () => false, phase: () => false, + test: () => false, // Used for coverage of universal/ undefined nodes } \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx index 580499e..9a496f2 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx @@ -40,14 +40,11 @@ export default function EndNode(props: NodeProps) { /** * Functionality for reducing this node into its more compact json program * @param node the node to reduce - * @param nodes all nodes present + * @param _nodes all nodes present * @returns Dictionary, {id: node.id} */ -export function EndReduce(node: Node, nodes: Node[]) { +export function EndReduce(node: Node, _nodes: Node[]) { // Replace this for nodes functionality - if (nodes.length <= -1) { - console.warn("Impossible nodes length in EndReduce") - } return { id: node.id } @@ -55,13 +52,9 @@ export function EndReduce(node: Node, nodes: Node[]) { /** * Any connection functionality that should get called when a connection is made to this node type (end) - * @param thisNode the node of which the functionality gets called - * @param otherNode the other node which has connected - * @param isThisSource whether this node is the one that is the source of the connection + * @param _thisNode the node of which the functionality gets called + * @param _otherNode the other node which has connected + * @param _isThisSource whether this node is the one that is the source of the connection */ -export function EndConnects(thisNode: Node, otherNode: Node, isThisSource: boolean) { - // Replace this for connection logic - if (thisNode == undefined && otherNode == undefined && isThisSource == false) { - console.warn("Impossible node connection called in EndConnects") - } +export function EndConnects(_thisNode: Node, _otherNode: Node, _isThisSource: boolean) { } \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx index 8cfa122..1149496 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx @@ -77,13 +77,9 @@ export default function GoalNode(props: NodeProps) { /** * Reduces each Goal, including its children down into its relevant data. * @param node: The Node Properties of this node. - * @param nodes: all the nodes in the graph + * @param _nodes: all the nodes in the graph */ -export function GoalReduce(node: Node, nodes: Node[]) { - // Replace this for nodes functionality - if (nodes.length <= -1) { - console.warn("Impossible nodes length in GoalReduce") - } +export function GoalReduce(node: Node, _nodes: Node[]) { const data = node.data as GoalNodeData; return { id: node.id, @@ -93,9 +89,6 @@ export function GoalReduce(node: Node, nodes: Node[]) { } } -export function GoalConnects(thisNode: Node, otherNode: Node, isThisSource: boolean) { +export function GoalConnects(_thisNode: Node, _otherNode: Node, _isThisSource: boolean) { // Replace this for connection logic - if (thisNode == undefined && otherNode == undefined && isThisSource == false) { - console.warn("Impossible node connection called in EndConnects") - } } \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx index 5789cac..1cbf257 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx @@ -61,13 +61,9 @@ export default function NormNode(props: NodeProps) { /** * Reduces each Norm, including its children down into its relevant data. * @param node: The Node Properties of this node. - * @param nodes: all the nodes in the graph + * @param _nodes: all the nodes in the graph */ -export function NormReduce(node: Node, nodes: Node[]) { - // Replace this for nodes functionality - if (nodes.length <= -1) { - console.warn("Impossible nodes length in NormReduce") - } +export function NormReduce(node: Node, _nodes: Node[]) { const data = node.data as NormNodeData; return { id: node.id, @@ -76,9 +72,5 @@ export function NormReduce(node: Node, nodes: Node[]) { } } -export function NormConnects(thisNode: Node, otherNode: Node, isThisSource: boolean) { - // Replace this for connection logic - if (thisNode == undefined && otherNode == undefined && isThisSource == false) { - console.warn("Impossible node connection called in EndConnects") - } +export function NormConnects(_thisNode: Node, _otherNode: Node, _isThisSource: boolean) { } \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx index 7234e34..06cb1e5 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx @@ -78,8 +78,10 @@ export function PhaseReduce(node: Node, nodes: Node[]) { .filter(([t]) => !nodesNotInPhase.includes(t)) .map(([t]) => t); - // children nodes - const childrenNodes = nodes.filter((node) => data.children.includes(node.id)); + // children nodes - make sure to check for empty arrays + let childrenNodes: any[] = []; + if (data.children) + childrenNodes = nodes.filter((node) => data.children.includes(node.id)); // Build the result object const result: Record = { diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx index 6d74c08..f994090 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx @@ -40,14 +40,11 @@ export default function StartNode(props: NodeProps) { /** * The reduce function for this node type. * @param node this node - * @param nodes all the nodes in the graph + * @param _nodes all the nodes in the graph * @returns a reduced structure of this node */ -export function StartReduce(node: Node, nodes: Node[]) { +export function StartReduce(node: Node, _nodes: Node[]) { // Replace this for nodes functionality - if (nodes.length <= -1) { - console.warn("Impossible nodes length in StartReduce") - } return { id: node.id } @@ -55,13 +52,9 @@ export function StartReduce(node: Node, nodes: Node[]) { /** * This function is called whenever a connection is made with this node type (start) - * @param thisNode the node of this node type which function is called - * @param otherNode the other node which was part of the connection - * @param isThisSource whether this instance of the node was the source in the connection, true = yes. + * @param _thisNode the node of this node type which function is called + * @param _otherNode the other node which was part of the connection + * @param _isThisSource whether this instance of the node was the source in the connection, true = yes. */ -export function StartConnects(thisNode: Node, otherNode: Node, isThisSource: boolean) { - // Replace this for connection logic - if (thisNode == undefined && otherNode == undefined && isThisSource == false) { - console.warn("Impossible node connection called in EndConnects") - } +export function StartConnects(_thisNode: Node, _otherNode: Node, _isThisSource: boolean) { } \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx index a6f114e..ed79c99 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx @@ -68,13 +68,9 @@ export default function TriggerNode(props: NodeProps) { /** * Reduces each Trigger, including its children down into its relevant data. * @param node: The Node Properties of this node. - * @param nodes: all the nodes in the graph. + * @param _nodes: all the nodes in the graph. */ -export function TriggerReduce(node: Node, nodes: Node[]) { - // Replace this for nodes functionality - if (nodes.length <= -1) { - console.warn("Impossible nodes length in TriggerReduce") - } +export function TriggerReduce(node: Node, _nodes: Node[]) { const data = node.data as TriggerNodeData; return { label: data.label, @@ -84,15 +80,12 @@ export function TriggerReduce(node: Node, nodes: Node[]) { /** * This function is called whenever a connection is made with this node type (trigger) - * @param thisNode the node of this node type which function is called - * @param otherNode the other node which was part of the connection - * @param isThisSource whether this instance of the node was the source in the connection, true = yes. + * @param _thisNode the node of this node type which function is called + * @param _otherNode the other node which was part of the connection + * @param _isThisSource whether this instance of the node was the source in the connection, true = yes. */ -export function TriggerConnects(thisNode: Node, otherNode: Node, isThisSource: boolean) { - // Replace this for connection logic - if (thisNode == undefined && otherNode == undefined && isThisSource == false) { - console.warn("Impossible node connection called in EndConnects") - } +export function TriggerConnects(_thisNode: Node, _otherNode: Node, _isThisSource: boolean) { + } // Definitions for the possible triggers, being keywords and emotions diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx index 7e1e9ca..1119860 100644 --- a/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx @@ -2,15 +2,19 @@ import { describe, beforeEach } from '@jest/globals'; import { screen } from '@testing-library/react'; import { renderWithProviders, resetFlowStore } from '../.././/./../../test-utils/test-utils'; import type { XYPosition } from '@xyflow/react'; -import { NodeTypes, NodeDefaults } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/NodeRegistry'; +import { NodeTypes, NodeDefaults, NodeConnects, NodeReduces, NodesInPhase } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/NodeRegistry'; import '@testing-library/jest-dom' +import { createElement } from 'react'; +import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores'; describe('NormNode', () => { - // let user: ReturnType; + beforeEach(() => { + resetFlowStore(); + jest.clearAllMocks(); + }); - // Copied from VisStores. - function createNode(id: string, type: string, position: XYPosition, data: Record, deletable? : boolean) { + function createNode(id: string, type: string, position: XYPosition, data: Record, deletable?: boolean) { const defaultData = NodeDefaults[type as keyof typeof NodeDefaults] const newData = { id: id, @@ -21,35 +25,122 @@ describe('NormNode', () => { } return {...defaultData, ...newData} } - - beforeEach(() => { - resetFlowStore(); - // user = userEvent.setup(); + + /** + * Reduces the graph into its phases' information and recursively calls their reducing function + */ + function graphReducer() { + const { nodes } = useFlowStore.getState(); + return nodes + .filter((n) => n.type == 'phase') + .map((n) => { + const reducer = NodeReduces['phase']; + return reducer(n, nodes) + }); + } + + function getAllTypes() { + return Object.entries(NodeTypes).map(([t])=>t) + } + + describe('Rendering', () => { + test.each(getAllTypes())('it should render %s node with the default data', (nodeType) => { + const lengthBefore = screen.getAllByText(/.*/).length; + + let newNode = createNode(nodeType + "1", nodeType, {x: 200, y:200}, {}) + let uiElement = Object.entries(NodeTypes).find(([t])=>t==nodeType)?.[1]!; + let props = { + id:newNode.id, + type:newNode.type as string, + data:newNode.data as any, + selected:false, + isConnectable:true, + zIndex:0, + dragging:false, + selectable:true, + deletable:true, + draggable:true, + positionAbsoluteX:0, + positionAbsoluteY:0,} + + renderWithProviders(createElement(uiElement as React.ComponentType, props)); + const lengthAfter = screen.getAllByText(/.*/).length; + + expect(lengthBefore + 1 == lengthAfter) + }); }); - describe('Rendering', () => { - test.each([Object.entries(NodeTypes)].map(([t])=>t))('it should render each node with the default data', (nodeType) => { - let newNode = createNode(nodeType + "1", nodeType, {x: 200, y:200}, {}) - let uiElement = Object.entries(NodeTypes).find(([t])=>t==nodeType)?.[1]!; - let props = { - id:newNode.id, - type:newNode.type as string, - data:newNode.data as any, - selected:false, - isConnectable:true, - zIndex:0, - dragging:false, - selectable:true, - deletable:true, - draggable:true, - positionAbsoluteX:0, - positionAbsoluteY:0,} - renderWithProviders(uiElement(props)); - const elements = screen.queryAllByText((content, ) => - content.toLowerCase().includes(nodeType.toLowerCase()) - ); - expect(elements.length).toBeGreaterThan(0); + + describe('Connecting', () => { + test.each(getAllTypes())('it should call the connect function when %s node is connected', (nodeType) => { + // Create two nodes - one of the current type and one to connect to + const sourceNode = createNode('source-1', nodeType, {x: 100, y: 100}, {}); + const targetNode = createNode('target-1', 'end', {x: 300, y: 100}, {}); + + // Add nodes to store + useFlowStore.setState({ nodes: [sourceNode, targetNode] }); + + // Spy on the connect functions + const sourceConnectSpy = jest.spyOn(NodeConnects, nodeType as keyof typeof NodeConnects); + const targetConnectSpy = jest.spyOn(NodeConnects, 'end'); + + // Simulate connection + useFlowStore.getState().onConnect({ + source: 'source-1', + target: 'target-1', + sourceHandle: null, + targetHandle: null, + }); + + // Verify the connect functions were called + expect(sourceConnectSpy).toHaveBeenCalledWith(sourceNode, targetNode, true); + expect(targetConnectSpy).toHaveBeenCalledWith(targetNode, sourceNode, false); + + sourceConnectSpy.mockRestore(); + targetConnectSpy.mockRestore(); + }); + }); + + describe('Reducing', () => { + test.each(getAllTypes())('it should correctly call/ not call the reduce function when %s node is in a phase', (nodeType) => { + // Create a phase node and a node of the current type + const phaseNode = createNode('phase-1', 'phase', {x: 200, y: 100}, { label: 'Test Phase', children: [] }); + const testNode = createNode('node-1', nodeType, {x: 100, y: 100}, {}); + + // Add the test node as a child of the phase + (phaseNode.data as any).children.push(testNode.id); + + // Add nodes to store + useFlowStore.setState({ nodes: [phaseNode, testNode] }); + + // Spy on the reduce functions + const phaseReduceSpy = jest.spyOn(NodeReduces, 'phase'); + const nodeReduceSpy = jest.spyOn(NodeReduces, nodeType as keyof typeof NodeReduces); + + // Simulate reducing - using the graphReducer + const result = graphReducer(); + + // Verify the reduce functions were called + expect(phaseReduceSpy).toHaveBeenCalledWith(phaseNode, [phaseNode, testNode]); + // Check if this node type is in NodesInPhase and returns false + const nodesInPhaseFunc = NodesInPhase[nodeType as keyof typeof NodesInPhase]; + if (nodesInPhaseFunc && nodesInPhaseFunc() === false && nodeType !== 'phase') { + // Node is NOT in phase, so it should NOT be called + expect(nodeReduceSpy).not.toHaveBeenCalled(); + } else { + // Node IS in phase, so it SHOULD be called + expect(nodeReduceSpy).toHaveBeenCalled(); + } + + // Verify the correct structure is present using NodesInPhase + expect(result).toHaveLength(nodeType !== 'phase' ? 1 : 2); + expect(result[0]).toHaveProperty('id', 'phase-1'); + expect(result[0]).toHaveProperty('label', 'Test Phase'); + + // Restore mocks + phaseReduceSpy.mockRestore(); + nodeReduceSpy.mockRestore(); }); }); }); \ No newline at end of file -- 2.49.1 From 7640c32830af06ee3a7976eb03b4f4fa7075a00c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Tue, 2 Dec 2025 12:47:38 +0100 Subject: [PATCH 121/184] fix: fix the creation of new phases so that the data is deepcloned instead of referenced --- .../visualProgrammingUI/components/DragDropSidebar.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx index fb4857e..e02f81a 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx @@ -56,7 +56,7 @@ function addNode(nodeType: keyof typeof NodeTypes, position: XYPosition) { // Find out if there's any default data about our ndoe const defaultData = NodeDefaults[nodeType] ?? {} - + // Currently, we find out what the Id is by checking the last node and adding one const sameTypeNodes = nodes.filter((node) => node.type === nodeType); const nextNumber = @@ -75,7 +75,9 @@ function addNode(nodeType: keyof typeof NodeTypes, position: XYPosition) { id: id, type: nodeType, position, - data: {...defaultData} + // Deep copy using JSON because thats how things work: + // Ref: https://developer.mozilla.org/en-US/docs/Glossary/Deep_copy + data: structuredClone(defaultData) } setNodes([...nodes, newNode]); } -- 2.49.1 From fe13017f2de094cca6b22fc8387ebcde06623e9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Tue, 2 Dec 2025 14:12:35 +0100 Subject: [PATCH 122/184] test: test for the actual better clone- and make sure we use the JSON stringify and parse for this since tests are weird ref: N25B-371 --- .../components/DragDropSidebar.tsx | 37 +----------------- .../visualProgrammingUI/utils/AddNode.ts | 39 +++++++++++++++++++ .../nodes/PhaseNode.test.tsx | 22 +++++++++++ .../nodes/UniversalNodes.test.tsx | 2 +- 4 files changed, 63 insertions(+), 37 deletions(-) create mode 100644 src/pages/VisProgPage/visualProgrammingUI/utils/AddNode.ts create mode 100644 test/pages/visProgPage/visualProgrammingUI/nodes/PhaseNode.test.tsx diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx index e02f81a..cf20e0a 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx @@ -1,9 +1,9 @@ import { useDraggable } from '@neodrag/react'; import { useReactFlow, type XYPosition } from '@xyflow/react'; import { type ReactNode, useCallback, useRef, useState } from 'react'; -import useFlowStore from '../VisProgStores'; import styles from '../../VisProg.module.css'; import { NodeDefaults, type NodeTypes } from '../NodeRegistry' +import addNode from '../utils/AddNode'; /** * DraggableNodeProps dictates the type properties of a DraggableNode @@ -47,41 +47,6 @@ function DraggableNode({ className, children, nodeType, onDrop }: DraggableNodeP ); } -/** - * addNode — adds a new node to the flow using the unified class-based system. - * Keeps numbering logic for phase/norm nodes. - */ -function addNode(nodeType: keyof typeof NodeTypes, position: XYPosition) { - const { nodes, setNodes } = useFlowStore.getState(); - - // Find out if there's any default data about our ndoe - const defaultData = NodeDefaults[nodeType] ?? {} - - // Currently, we find out what the Id is by checking the last node and adding one - const sameTypeNodes = nodes.filter((node) => node.type === nodeType); - const nextNumber = - sameTypeNodes.length > 0 - ? (() => { - const lastNode = sameTypeNodes[sameTypeNodes.length - 1]; - const parts = lastNode.id.split('-'); - const lastNum = Number(parts[1]); - return Number.isNaN(lastNum) ? sameTypeNodes.length + 1 : lastNum + 1; - })() - : 1; - const id = `${nodeType}-${nextNumber}`; - - // Create new node - const newNode = { - id: id, - type: nodeType, - position, - // Deep copy using JSON because thats how things work: - // Ref: https://developer.mozilla.org/en-US/docs/Glossary/Deep_copy - data: structuredClone(defaultData) - } - setNodes([...nodes, newNode]); -} - /** * DndToolbar defines how the drag and drop toolbar component works * and includes the default onDrop behavior. diff --git a/src/pages/VisProgPage/visualProgrammingUI/utils/AddNode.ts b/src/pages/VisProgPage/visualProgrammingUI/utils/AddNode.ts new file mode 100644 index 0000000..b73d46b --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/utils/AddNode.ts @@ -0,0 +1,39 @@ +import type { XYPosition } from "@xyflow/react"; +import { NodeDefaults, type NodeTypes } from "../NodeRegistry"; +import useFlowStore from "../VisProgStores"; + + +/** + * addNode — adds a new node to the flow using the unified class-based system. + * Keeps numbering logic for phase/norm nodes. + */ +export default function addNode(nodeType: keyof typeof NodeTypes, position: XYPosition) { + const { nodes, setNodes } = useFlowStore.getState(); + + // Find out if there's any default data about our ndoe + const defaultData = NodeDefaults[nodeType] ?? {} + + // Currently, we find out what the Id is by checking the last node and adding one + const sameTypeNodes = nodes.filter((node) => node.type === nodeType); + const nextNumber = + sameTypeNodes.length > 0 + ? (() => { + const lastNode = sameTypeNodes[sameTypeNodes.length - 1]; + const parts = lastNode.id.split('-'); + const lastNum = Number(parts[1]); + return Number.isNaN(lastNum) ? sameTypeNodes.length + 1 : lastNum + 1; + })() + : 1; + const id = `${nodeType}-${nextNumber}`; + + // Create new node + const newNode = { + id: id, + type: nodeType, + position, + // Deep copy using JSON because thats how things work: + // Ref: https://developer.mozilla.org/en-US/docs/Glossary/Deep_copy + data: JSON.parse(JSON.stringify(defaultData)) + } + setNodes([...nodes, newNode]); +} \ No newline at end of file diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/PhaseNode.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/PhaseNode.test.tsx new file mode 100644 index 0000000..b37c23a --- /dev/null +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/PhaseNode.test.tsx @@ -0,0 +1,22 @@ +import { resetFlowStore } from "../../../../test-utils/test-utils"; +import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores'; +import addNode from "../../../../../src/pages/VisProgPage/visualProgrammingUI/utils/AddNode"; + + +describe('PhaseNode', () => { + beforeEach(() => resetFlowStore()); + + it('each created phase gets its own children array (store-level)', () => { + addNode("phase", {x:10,y:10}) + addNode("phase", {x:20,y:20}) + + const nodes = useFlowStore.getState().nodes; + const p1 = nodes.find((x) => x.id === 'phase-1')!; + const p2 = nodes.find((x) => x.id === 'phase-2')!; + + // not the same reference + expect(p1.data.children).not.toBe(p2.data.children); + // but same initial value + expect(p1.data.children).toEqual(p2.data.children); + }); +}); \ No newline at end of file diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx index 1119860..182ff53 100644 --- a/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx @@ -15,7 +15,7 @@ describe('NormNode', () => { }); function createNode(id: string, type: string, position: XYPosition, data: Record, deletable?: boolean) { - const defaultData = NodeDefaults[type as keyof typeof NodeDefaults] + const defaultData = JSON.parse(JSON.stringify(NodeDefaults[type as keyof typeof NodeDefaults])) const newData = { id: id, type: type, -- 2.49.1 From 3d7997e8d04796fecffb3d5a43338e1e9f9e26ee Mon Sep 17 00:00:00 2001 From: Twirre Meulenbelt <43213592+TwirreM@users.noreply.github.com> Date: Tue, 2 Dec 2025 15:02:48 +0100 Subject: [PATCH 123/184] feat: introduce git hooks Make installing git hooks easy using Husky. Also, updating the commit message checks. Includes setup instructions in the README. ref: N25B-366 --- .githooks/check-branch-name.sh | 77 ++++++++++++++++++ .githooks/check-commit-msg.sh | 138 +++++++++++++++++++++++++++++++++ .githooks/commit-msg | 16 ---- .githooks/pre-commit | 17 ---- .githooks/prepare-commit-msg | 9 --- .husky/commit-msg | 1 + .husky/pre-commit | 3 + README.md | 19 +++-- package-lock.json | 17 ++++ package.json | 6 +- 10 files changed, 251 insertions(+), 52 deletions(-) create mode 100755 .githooks/check-branch-name.sh create mode 100755 .githooks/check-commit-msg.sh delete mode 100755 .githooks/commit-msg delete mode 100755 .githooks/pre-commit delete mode 100755 .githooks/prepare-commit-msg create mode 100644 .husky/commit-msg create mode 100644 .husky/pre-commit diff --git a/.githooks/check-branch-name.sh b/.githooks/check-branch-name.sh new file mode 100755 index 0000000..6a6669a --- /dev/null +++ b/.githooks/check-branch-name.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash + +# This script checks if the current branch name follows the specified format. +# It's designed to be used as a 'pre-commit' git hook. + +# Format: / +# Example: feat/add-user-login + +# --- Configuration --- +# An array of allowed commit types +ALLOWED_TYPES=(feat fix refactor perf style test docs build chore revert) +# An array of branches to ignore +IGNORED_BRANCHES=(main dev demo) + +# --- Colors for Output --- +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# --- Helper Functions --- +error_exit() { + echo -e "${RED}ERROR: $1${NC}" >&2 + echo -e "${YELLOW}Branch name format is incorrect. Aborting commit.${NC}" >&2 + exit 1 +} + +# --- Main Logic --- + +# 1. Get the current branch name +BRANCH_NAME=$(git symbolic-ref --short HEAD) + +# 2. Check if the current branch is in the ignored list +for ignored_branch in "${IGNORED_BRANCHES[@]}"; do + if [ "$BRANCH_NAME" == "$ignored_branch" ]; then + echo -e "${GREEN}Branch check skipped for default branch: $BRANCH_NAME${NC}" + exit 0 + fi +done + +# 3. Validate the overall structure: / +if ! [[ "$BRANCH_NAME" =~ ^[a-z]+/.+$ ]]; then + error_exit "Branch name must be in the format: /\nExample: feat/add-user-login" +fi + +# 4. Extract the type and description +TYPE=$(echo "$BRANCH_NAME" | cut -d'/' -f1) +DESCRIPTION=$(echo "$BRANCH_NAME" | cut -d'/' -f2-) + +# 5. Validate the +type_valid=false +for allowed_type in "${ALLOWED_TYPES[@]}"; do + if [ "$TYPE" == "$allowed_type" ]; then + type_valid=true + break + fi +done + +if [ "$type_valid" == false ]; then + error_exit "Invalid type '$TYPE'.\nAllowed types are: ${ALLOWED_TYPES[*]}" +fi + +# 6. Validate the +# Regex breakdown: +# ^[a-z0-9]+ - Starts with one or more lowercase letters/numbers (the first word). +# (-[a-z0-9]+){0,5} - Followed by a group of (dash + word) 0 to 5 times. +# $ - End of the string. +# This entire pattern enforces 1 to 6 words total, separated by dashes. +DESCRIPTION_REGEX="^[a-z0-9]+(-[a-z0-9]+){0,5}$" + +if ! [[ "$DESCRIPTION" =~ $DESCRIPTION_REGEX ]]; then + error_exit "Invalid short description '$DESCRIPTION'.\nIt must be a maximum of 6 words, all lowercase, separated by dashes.\nExample: add-new-user-authentication-feature" +fi + +# If all checks pass, exit successfully +echo -e "${GREEN}Branch name '$BRANCH_NAME' is valid.${NC}" +exit 0 diff --git a/.githooks/check-commit-msg.sh b/.githooks/check-commit-msg.sh new file mode 100755 index 0000000..2dd592c --- /dev/null +++ b/.githooks/check-commit-msg.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash + +# This script checks if a commit message follows the specified format. +# It's designed to be used as a 'commit-msg' git hook. + +# Format: +# : +# +# [optional] +# +# [ref/close]: + +# --- Configuration --- +# An array of allowed commit types +ALLOWED_TYPES=(feat fix refactor perf style test docs build chore revert) + +# --- Colors for Output --- +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# The first argument to the hook is the path to the file containing the commit message +COMMIT_MSG_FILE=$1 + +# --- Automated Commit Detection --- + +# Read the first line (header) for initial checks +HEADER=$(head -n 1 "$COMMIT_MSG_FILE") + +echo 'Given commit message:' +echo $HEADER + +# Check for Merge commits (covers 'git merge' and PR merges from GitHub/GitLab) +# Examples: "Merge branch 'main' into ...", "Merge pull request #123 from ..." +MERGE_PATTERN="^Merge (remote-tracking )?(branch|pull request|tag) .*" +if [[ "$HEADER" =~ $MERGE_PATTERN ]]; then + echo -e "${GREEN}Merge commit detected by message content. Skipping validation.${NC}" + exit 0 +fi + +# Check for Revert commits +# Example: "Revert "feat: add new feature"" +REVERT_PATTERN="^Revert \".*\"" +if [[ "$HEADER" =~ $REVERT_PATTERN ]]; then + echo -e "${GREEN}Revert commit detected by message content. Skipping validation.${NC}" + exit 0 +fi + +# Check for Cherry-pick commits (this pattern appears at the end of the message) +# Example: "(cherry picked from commit deadbeef...)" +# We use grep -q to search the whole file quietly. +CHERRY_PICK_PATTERN="\(cherry picked from commit [a-f0-9]{7,40}\)" +if grep -qE "$CHERRY_PICK_PATTERN" "$COMMIT_MSG_FILE"; then + echo -e "${GREEN}Cherry-pick detected by message content. Skipping validation.${NC}" + exit 0 +fi + +# Check for Squash +# Example: "Squash commits ..." +SQUASH_PATTERN="^Squash .+" +if [[ "$HEADER" =~ $SQUASH_PATTERN ]]; then + echo -e "${GREEN}Squash commit detected by message content. Skipping validation.${NC}" + exit 0 +fi + +# --- Validation Functions --- + +# Function to print an error message and exit +# Usage: error_exit "Your error message here" +error_exit() { + # >&2 redirects echo to stderr + echo -e "${RED}ERROR: $1${NC}" >&2 + echo -e "${YELLOW}Commit message format is incorrect. Aborting commit.${NC}" >&2 + exit 1 +} + +# --- Main Logic --- + +# 1. Read the header (first line) of the commit message +HEADER=$(head -n 1 "$COMMIT_MSG_FILE") + +# 2. Validate the header format: : +# Regex breakdown: +# ^(type1|type2|...) - Starts with one of the allowed types +# : - Followed by a literal colon +# \s - Followed by a single space +# .+ - Followed by one or more characters for the description +# $ - End of the line +TYPES_REGEX=$( + IFS="|" + echo "${ALLOWED_TYPES[*]}" +) +HEADER_REGEX="^($TYPES_REGEX): .+$" + +if ! [[ "$HEADER" =~ $HEADER_REGEX ]]; then + error_exit "Invalid header format.\n\nHeader must be in the format: : \nAllowed types: ${ALLOWED_TYPES[*]}\nExample: feat: add new user authentication feature" +fi + +# Only validate footer if commit type is not chore +TYPE=$(echo "$HEADER" | cut -d':' -f1) +if [ "$TYPE" != "chore" ]; then + # 3. Validate the footer (last line) of the commit message + FOOTER=$(tail -n 1 "$COMMIT_MSG_FILE") + + # Regex breakdown: + # ^(ref|close) - Starts with 'ref' or 'close' + # : - Followed by a literal colon + # \s - Followed by a single space + # N25B- - Followed by the literal string 'N25B-' + # [0-9]+ - Followed by one or more digits + # $ - End of the line + FOOTER_REGEX="^(ref|close): N25B-[0-9]+$" + + if ! [[ "$FOOTER" =~ $FOOTER_REGEX ]]; then + error_exit "Invalid footer format.\n\nFooter must be in the format: [ref/close]: \nExample: ref: N25B-123" + fi +fi + +# 4. If the message has more than 2 lines, validate the separator +# A blank line must exist between the header and the body. +LINE_COUNT=$(wc -l <"$COMMIT_MSG_FILE" | xargs) # xargs trims whitespace + +# We only care if there is a body. Header + Footer = 2 lines. +# Header + Blank Line + Body... + Footer > 2 lines. +if [ "$LINE_COUNT" -gt 2 ]; then + # Get the second line + SECOND_LINE=$(sed -n '2p' "$COMMIT_MSG_FILE") + + # Check if the second line is NOT empty. If it's not, it's an error. + if [ -n "$SECOND_LINE" ]; then + error_exit "Missing blank line between header and body.\n\nThe second line of your commit message must be empty if a body is present." + fi +fi + +# If all checks pass, exit with success +echo -e "${GREEN}Commit message is valid.${NC}" +exit 0 diff --git a/.githooks/commit-msg b/.githooks/commit-msg deleted file mode 100755 index 41992ad..0000000 --- a/.githooks/commit-msg +++ /dev/null @@ -1,16 +0,0 @@ -#!/bin/sh - -commit_msg_file=$1 -commit_msg=$(cat "$commit_msg_file") - -if echo "$commit_msg" | grep -Eq "^(feat|fix|refactor|perf|style|test|docs|build|chore|revert): .+"; then - if echo "$commit_msg" | grep -Eq "^(ref|close):\sN25B-.+"; then - exit 0 - else - echo "❌ Commit message invalid! Must end with [ref/close]: N25B-000" - exit 1 - fi -else - echo "❌ Commit message invalid! Must start with : " - exit 1 -fi \ No newline at end of file diff --git a/.githooks/pre-commit b/.githooks/pre-commit deleted file mode 100755 index 7e94937..0000000 --- a/.githooks/pre-commit +++ /dev/null @@ -1,17 +0,0 @@ -#!/bin/sh - -# Get current branch -branch=$(git rev-parse --abbrev-ref HEAD) - -if echo "$branch" | grep -Eq "(dev|main)"; then - echo 0 -fi - -# allowed pattern -if echo "$branch" | grep -Eq "^(feat|fix|refactor|perf|style|test|docs|build|chore|revert)\/\w+(-\w+){0,5}$"; then - exit 0 -else - echo "❌ Invalid branch name: $branch" - echo "Branch must be named / (must have one to six words separated by a dash)" - exit 1 -fi \ No newline at end of file diff --git a/.githooks/prepare-commit-msg b/.githooks/prepare-commit-msg deleted file mode 100755 index 5b706c1..0000000 --- a/.githooks/prepare-commit-msg +++ /dev/null @@ -1,9 +0,0 @@ -#!/bin/sh - -echo "#: - -#[optional body] - -#[optional footer(s)] - -#[ref/close]: " > $1 \ No newline at end of file diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100644 index 0000000..297870d --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1 @@ +sh .githooks/check-commit-msg.sh $1 diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..822552c --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,3 @@ +sh .githooks/check-branch-name.sh + +npm run lint diff --git a/README.md b/README.md index 9d8fee2..47e2054 100644 --- a/README.md +++ b/README.md @@ -28,18 +28,21 @@ npm run dev It should automatically reload when you save changes. -## GitHooks +## Git Hooks -To activate automatic commits/branch name checks run: +To activate automatic linting, branch name checks and commit message checks, run: -```shell -git config --local core.hooksPath .githooks +```bash +npm run prepare ``` -If your commit fails its either: -branch name != /description-of-branch , -commit name != : description of the commit. - : N25B-Num's +You might get an error along the lines of `Can't install pre-commit with core.hooksPath` set. To fix this, simply unset the hooksPath by running: + +```bash +git config --local --unset core.hooksPath +``` + +Then run the pre-commit install commands again. ## Documentation diff --git a/package-lock.json b/package-lock.json index 395326d..b225239 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", "globals": "^16.4.0", + "husky": "^9.1.7", "identity-obj-proxy": "^3.0.0", "jest": "^30.2.0", "jest-environment-jsdom": "^30.2.0", @@ -5051,6 +5052,22 @@ "node": ">=10.17.0" } }, + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", diff --git a/package.json b/package.json index a493ed2..6e7ea28 100644 --- a/package.json +++ b/package.json @@ -6,8 +6,9 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build", - "lint": "eslint .", - "preview": "vite preview" + "lint": "eslint src test", + "preview": "vite preview", + "prepare": "husky" }, "dependencies": { "@neodrag/react": "^2.3.1", @@ -30,6 +31,7 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", "globals": "^16.4.0", + "husky": "^9.1.7", "identity-obj-proxy": "^3.0.0", "jest": "^30.2.0", "jest-environment-jsdom": "^30.2.0", -- 2.49.1 From 7d3c63630a091d0d4b6bd79607ef74b1777edb3b Mon Sep 17 00:00:00 2001 From: Twirre Meulenbelt <43213592+TwirreM@users.noreply.github.com> Date: Tue, 2 Dec 2025 16:06:14 +0100 Subject: [PATCH 124/184] feat: introduce CI/CD runner Installs dependencies, checks style, runs tests. ref: N25B-366 --- .gitlab-ci.yml | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 .gitlab-ci.yml diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..46727b7 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,40 @@ +# ---------- GLOBAL SETUP ---------- # +workflow: + rules: + - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' + +stages: + - install + - lint + - test + +variables: + NODE_VERSION: "24.11.1" + BASE_LAYER: trixie-slim + +default: + image: docker.io/library/node:${NODE_VERSION}-${BASE_LAYER} + +# --------- INSTALLING --------- # +install: + stage: install + tags: + - install + script: + - npm ci + +# ---------- LINTING ---------- # +lint: + stage: lint + tags: + - lint + script: + - npm run lint + +# ---------- TESTING ---------- # +test: + stage: test + tags: + - test + script: + - npm run test -- 2.49.1 From ea85a05f273c9749c144c430a0644f195f5a2ff5 Mon Sep 17 00:00:00 2001 From: Twirre Meulenbelt <43213592+TwirreM@users.noreply.github.com> Date: Tue, 2 Dec 2025 16:42:00 +0100 Subject: [PATCH 125/184] fix: use install artifacts Uses install artifacts in later stages. ref: N25B-366 --- .gitlab-ci.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 46727b7..371c0a9 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -14,6 +14,11 @@ variables: default: image: docker.io/library/node:${NODE_VERSION}-${BASE_LAYER} + cache: + key: "${CI_COMMIT_REF_SLUG}" + paths: + - node_modules/ + policy: pull-push # --------- INSTALLING --------- # install: @@ -22,10 +27,16 @@ install: - install script: - npm ci + artifacts: + paths: + - node_modules/ + expire_in: 1h # ---------- LINTING ---------- # lint: stage: lint + needs: + - install tags: - lint script: @@ -34,6 +45,8 @@ lint: # ---------- TESTING ---------- # test: stage: test + needs: + - install tags: - test script: -- 2.49.1 From e680ad3195d0469ea10c076199f3e5b9df52dd21 Mon Sep 17 00:00:00 2001 From: Twirre Meulenbelt <43213592+TwirreM@users.noreply.github.com> Date: Tue, 2 Dec 2025 16:46:56 +0100 Subject: [PATCH 126/184] fix: add `test` script to package.json ref: N25B-366 --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 6e7ea28..cd08dca 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "build": "tsc -b && vite build", "lint": "eslint src test", "preview": "vite preview", + "test": "jest", "prepare": "husky" }, "dependencies": { -- 2.49.1 From d9faeafe320800278beedfc2fe4ac288100fc290 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Wed, 3 Dec 2025 11:28:15 +0100 Subject: [PATCH 127/184] test: create test for phase node to account for the previous bug. ref: N25B-371 --- .../nodes/PhaseNode.test.tsx | 29 +++++++++++++++---- 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/PhaseNode.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/PhaseNode.test.tsx index b37c23a..43763d3 100644 --- a/test/pages/visProgPage/visualProgrammingUI/nodes/PhaseNode.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/PhaseNode.test.tsx @@ -1,22 +1,39 @@ -import { resetFlowStore } from "../../../../test-utils/test-utils"; import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores'; import addNode from "../../../../../src/pages/VisProgPage/visualProgrammingUI/utils/AddNode"; +import type { PhaseNodeData } from "../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode"; describe('PhaseNode', () => { - beforeEach(() => resetFlowStore()); - - it('each created phase gets its own children array (store-level)', () => { + it('each created phase gets its own children array, not the same reference ', () => { + // Create nodes addNode("phase", {x:10,y:10}) addNode("phase", {x:20,y:20}) + addNode("norm", {x:30,y:30}) + addNode("norm", {x:40,y:40}) + addNode("goal", {x:50,y:50}) + // Find nodes const nodes = useFlowStore.getState().nodes; const p1 = nodes.find((x) => x.id === 'phase-1')!; const p2 = nodes.find((x) => x.id === 'phase-2')!; - // not the same reference + // expect same value, not same reference expect(p1.data.children).not.toBe(p2.data.children); - // but same initial value expect(p1.data.children).toEqual(p2.data.children); + + // Add nodes to children + let p1_data = p1.data as PhaseNodeData; + let p2_data = p2.data as PhaseNodeData; + p1_data.children.push("norm-1"); + p2_data.children.push("norm-2"); + p2_data.children.push("goal-1"); + + // check that after adding, its not the same reference, and its not the same children + expect(p1.data.children).not.toBe(p2.data.children); + expect(p1.data.children).not.toEqual(p2.data.children); + + // expect them to have the correct length. + expect(p1_data.children.length == 1); + expect(p2_data.children.length == 2); }); }); \ No newline at end of file -- 2.49.1 From c167144b4d0ae8dcb962b9eb353bc6a254624c0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Wed, 3 Dec 2025 11:41:14 +0100 Subject: [PATCH 128/184] fix: fix eslint issues, adjust norm test for dev merge ref: N25B-371 --- .../visualProgrammingUI/nodes/PhaseNode.tsx | 2 +- .../visualProgrammingUI/nodes/NormNode.test.tsx | 4 ++-- .../visualProgrammingUI/nodes/PhaseNode.test.tsx | 4 ++-- .../visualProgrammingUI/nodes/TriggerNode.test.tsx | 10 ++++++++-- .../visualProgrammingUI/nodes/UniversalNodes.test.tsx | 7 ++++--- test/test-utils/test-utils.tsx | 2 -- 6 files changed, 17 insertions(+), 12 deletions(-) diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx index 06cb1e5..56c762c 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx @@ -79,7 +79,7 @@ export function PhaseReduce(node: Node, nodes: Node[]) { .map(([t]) => t); // children nodes - make sure to check for empty arrays - let childrenNodes: any[] = []; + let childrenNodes: Node[] = []; if (data.children) childrenNodes = nodes.filter((node) => data.children.includes(node.id)); diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx index 9e3d049..598d687 100644 --- a/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx @@ -115,8 +115,8 @@ describe('NormNode', () => { /> ); - let norm = screen.getByText("Norm :") - expect(norm).toBeInTheDocument; + const norm = screen.getByText("Norm :") + expect(norm).toBeInTheDocument(); }); it('should render with dragging state', () => { diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/PhaseNode.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/PhaseNode.test.tsx index 43763d3..006380c 100644 --- a/test/pages/visProgPage/visualProgrammingUI/nodes/PhaseNode.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/PhaseNode.test.tsx @@ -22,8 +22,8 @@ describe('PhaseNode', () => { expect(p1.data.children).toEqual(p2.data.children); // Add nodes to children - let p1_data = p1.data as PhaseNodeData; - let p2_data = p2.data as PhaseNodeData; + const p1_data = p1.data as PhaseNodeData; + const p2_data = p2.data as PhaseNodeData; p1_data.children.push("norm-1"); p2_data.children.push("norm-2"); p2_data.children.push("goal-1"); diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/TriggerNode.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/TriggerNode.test.tsx index a7c5437..9b1ff49 100644 --- a/test/pages/visProgPage/visualProgrammingUI/nodes/TriggerNode.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/TriggerNode.test.tsx @@ -197,8 +197,14 @@ describe('TriggerNode', () => { const result = TriggerReduce(triggerNode, allNodes); expect(result).toEqual({ - label: 'Keyword Trigger', - list: [{ id: 'kw1', keyword: 'hello' }], + id: "trigger-1", + type: "keywords", + label: 'Keyword Trigger', + keywords: [ + { + "id": "kw1", + "keyword": "hello", + },], }); }); }); diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx index 182ff53..80e52b4 100644 --- a/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx @@ -48,9 +48,10 @@ describe('NormNode', () => { test.each(getAllTypes())('it should render %s node with the default data', (nodeType) => { const lengthBefore = screen.getAllByText(/.*/).length; - let newNode = createNode(nodeType + "1", nodeType, {x: 200, y:200}, {}) - let uiElement = Object.entries(NodeTypes).find(([t])=>t==nodeType)?.[1]!; - let props = { + const newNode = createNode(nodeType + "1", nodeType, {x: 200, y:200}, {}) + const uiElement = Object.entries(NodeTypes).find(([t])=>t==nodeType)?.[1]; + expect(uiElement).toBeDefined(); + const props = { id:newNode.id, type:newNode.type as string, data:newNode.data as any, diff --git a/test/test-utils/test-utils.tsx b/test/test-utils/test-utils.tsx index 76878b9..1ad371a 100644 --- a/test/test-utils/test-utils.tsx +++ b/test/test-utils/test-utils.tsx @@ -31,5 +31,3 @@ export function resetFlowStore() { }); } -// Re-export everything from testing library -export * from '@testing-library/react'; \ No newline at end of file -- 2.49.1 From 413fb05cd8f69b5782ebfcc130355c42863f90cb Mon Sep 17 00:00:00 2001 From: Pim Hutting Date: Thu, 4 Dec 2025 09:12:01 +0100 Subject: [PATCH 129/184] chore: applied feedback from merge request Removed all the DOM manipulations and created a utils file so npx eslint is happy. Also changed the tests to test the new version of the code. ref: N25B-189 --- src/pages/VisProgPage/VisProg.module.css | 19 ++ .../components/SaveLoadPanel.tsx | 187 +++++------ src/utils/SaveLoad.ts | 19 ++ .../components/SaveLoadPanel.test.tsx | 298 ++++++++---------- 4 files changed, 261 insertions(+), 262 deletions(-) create mode 100644 src/utils/SaveLoad.ts diff --git a/src/pages/VisProgPage/VisProg.module.css b/src/pages/VisProgPage/VisProg.module.css index 0ad6bf2..df38224 100644 --- a/src/pages/VisProgPage/VisProg.module.css +++ b/src/pages/VisProgPage/VisProg.module.css @@ -135,3 +135,22 @@ filter: drop-shadow(0 0 0.25rem red); } +.save-button-like { + padding: 3px 10px; + background-color: canvas; + border-radius: 5pt; + outline: dodgerblue solid 2pt; + filter: drop-shadow(0 0 0.25rem dodgerblue); +} + +a.save-button-like { + display: inline-block; + text-decoration: none; + color: inherit; + cursor: pointer; + transition: filter 200ms, background-color 200ms; +} + +a.save-button-like:hover { + filter: drop-shadow(0 0 0.5rem dodgerblue); +} \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/SaveLoadPanel.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/SaveLoadPanel.tsx index b4adc47..7e34147 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/SaveLoadPanel.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/SaveLoadPanel.tsx @@ -1,133 +1,112 @@ +import { useRef, useState, useEffect } from "react"; import useFlowStore from "../VisProgStores"; import styles from "../../VisProg.module.css"; -import {type Edge } from "@xyflow/react"; -import type { AppNode } from "../VisProgTypes"; import { cleanup } from "@testing-library/react"; - -type SavedProject = { - version: 1; - name: string; - savedAt: string; // ISO timestamp - nodes: AppNode[]; - edges: Edge[]; -}; - - - -export function makeProjectBlob(name: string, nodes: AppNode[], edges: Edge[]): Blob { - const payload = { - version: 1, - name, - savedAt: new Date().toISOString(), - nodes, - edges, - }; - return new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" }); -} - -async function saveWithPicker(defaultName: string, blob: Blob) { - // @ts-expect-error: not in lib.dom.d.ts everywhere - if (window.showSaveFilePicker) { - // @ts-expect-error - const handle = await window.showSaveFilePicker({ - suggestedName: `${defaultName}.visprog.json`, - types: [{ description: "Visual Program Project", accept: { "application/json": [".visprog.json", ".json"] } }], - }); - const writable = await handle.createWritable(); - await writable.write(blob); - await writable.close(); - return; - } - // Fallback if File system API is not supported - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = `${defaultName}.visprog.json`; - a.click(); - URL.revokeObjectURL(url); -} - -async function loadWithPicker(): Promise { - try { - // @ts-expect-error - if (window.showOpenFilePicker) { - // @ts-expect-error - const [handle] = await window.showOpenFilePicker({ - multiple: false, - types: [{ description: "Visual Program Project", accept: { "application/json": [".visprog.json", ".json", ".txt"] } }], - }); - const file = await handle.getFile(); - return JSON.parse(await file.text()) as SavedProject; - } - // Fallback: input - return await new Promise((resolve) => { - const input = document.createElement("input"); - input.type = "file"; - input.accept = ".visprog.json,.json,.txt,application/json,text/plain"; - input.onchange = async () => { - const file = input.files?.[0]; - if (!file) return resolve(null); - try { - resolve(JSON.parse(await file.text()) as SavedProject); - } catch { - resolve(null); - } - }; - input.click(); - }); - } catch { - return null; - } -} +import { makeProjectBlob, type SavedProject } from "../../../../utils/SaveLoad"; export default function SaveLoadPanel() { - const nodes = useFlowStore((s) => s.nodes) as AppNode[]; - const edges = useFlowStore((s) => s.edges) as Edge[]; + const nodes = useFlowStore((s) => s.nodes); + const edges = useFlowStore((s) => s.edges); const setNodes = useFlowStore((s) => s.setNodes); const setEdges = useFlowStore((s) => s.setEdges); + const [saveUrl, setSaveUrl] = useState(null); + + // ref to the file input + const inputRef = useRef(null); + // ref to hold the resolver for the currently pending load promise + const resolverRef = useRef<((p: SavedProject | null) => void) | null>(null); + + useEffect(() => { + return () => { + if (resolverRef.current) { + resolverRef.current(null); + resolverRef.current = null; + } + }; + }, []); + const onSave = async () => { - try { - const nameGuess = - (nodes.find((n) => n.type === "start")?.data?.label as string) || "visual-program"; - const blob = makeProjectBlob(nameGuess, nodes, edges); - await saveWithPicker(nameGuess, blob); - } catch (e) { - console.error(e); - alert("Saving failed. See console."); - } + const nameGuess = "visual-program"; + const blob = makeProjectBlob(nameGuess, nodes, edges); + const url = URL.createObjectURL(blob); + setSaveUrl(url); }; const onLoad = async () => { try { - const proj = await loadWithPicker(); + const proj = await new Promise((resolve) => { + resolverRef.current = resolve; + inputRef.current?.click(); + }); + // clear stored resolver + resolverRef.current = null; + if (!proj) return; - if (proj.version !== 1 || !Array.isArray(proj.nodes) || !Array.isArray(proj.edges)) { - alert("Invalid project file format."); - return; - } - - //We clear all the current edges and nodes cleanup(); - //set all loaded nodes and edges into the VisProg - const loadedNodes = proj.nodes as AppNode[]; - const loadedEdges = proj.edges as Edge[]; - setNodes(loadedNodes); - setEdges(loadedEdges); - + setNodes(proj.nodes); + setEdges(proj.edges); } catch (e) { console.error(e); alert("Loading failed. See console."); } }; + // input change handler resolves the onLoad promise with parsed project or null + const handleFileChange = async (e: React.ChangeEvent) => { + try { + const file = e.target.files?.[0]; + if (!file) { + resolverRef.current?.(null); + resolverRef.current = null; + return; + } + try { + const text = await file.text(); + const parsed = JSON.parse(text) as SavedProject; + resolverRef.current?.(parsed ?? null); + } catch { + resolverRef.current?.(null); + } finally { + // allow re-selecting same file next time + if (inputRef.current) inputRef.current.value = ""; + resolverRef.current = null; + } + } catch { + resolverRef.current?.(null); + resolverRef.current = null; + } + }; + + const defaultName = "visual-program"; return (
    ); diff --git a/src/utils/SaveLoad.ts b/src/utils/SaveLoad.ts new file mode 100644 index 0000000..4ea9666 --- /dev/null +++ b/src/utils/SaveLoad.ts @@ -0,0 +1,19 @@ +import {type Edge, type Node } from "@xyflow/react"; + +export type SavedProject = { + name: string; + savedASavedProject: string; // ISO timestamp + nodes: Node[]; + edges: Edge[]; +}; + +// Creates a JSON Blob containing the current visual program (nodes + edges) +export function makeProjectBlob(name: string, nodes: Node[], edges: Edge[]): Blob { + const payload = { + name, + savedAt: new Date().toISOString(), + nodes, + edges, + }; + return new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" }); +} \ No newline at end of file diff --git a/test/pages/visProgPage/visualProgrammingUI/components/SaveLoadPanel.test.tsx b/test/pages/visProgPage/visualProgrammingUI/components/SaveLoadPanel.test.tsx index 36fed6c..97bbf11 100644 --- a/test/pages/visProgPage/visualProgrammingUI/components/SaveLoadPanel.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/components/SaveLoadPanel.test.tsx @@ -1,153 +1,15 @@ -import { mockReactFlow } from '../../../../setupFlowTests.ts'; -import { act, render, screen, fireEvent } from '@testing-library/react'; +// SaveLoadPanel.all.test.tsx +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx'; -import { addNode } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx'; -import { makeProjectBlob } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/components/SaveLoadPanel.tsx'; import SaveLoadPanel from '../../../../../src/pages/VisProgPage/visualProgrammingUI/components/SaveLoadPanel.tsx'; +import { makeProjectBlob } from '../../../../../src/utils/SaveLoad.ts'; +import { mockReactFlow } from "../../../../setupFlowTests.ts"; // optional helper if present -beforeAll(() => { - mockReactFlow(); -}); - -beforeEach(() => { - const { setNodes, setEdges } = useFlowStore.getState(); - act(() => { - setNodes([]); - setEdges([]); - }); -}); - -afterEach(() => { - jest.restoreAllMocks(); -}); - -describe('Load and save panel', () => { - test('save and load functions work correctly', async () => { - // create two nodes via your sidebar API - act(() => { - addNode('phase', { x: 100, y: 100 }); - addNode('norm', { x: 200, y: 200 }); - }); - - const initialState = useFlowStore.getState(); - expect(initialState.nodes.length).toBe(2); - - // make blob from current nodes/edges - const blob = makeProjectBlob('test-project', initialState.nodes, initialState.edges); - - // simulate loading from that blob - const parsed = JSON.parse(await blobToText(blob)); - - act(() => { - const { setNodes, setEdges } = useFlowStore.getState(); - setEdges([]); // clear edges first (mirrors app behavior) - setNodes(parsed.nodes); - setEdges(parsed.edges); - }); - - const loadedState = useFlowStore.getState(); - expect(loadedState.nodes.length).toBe(2); - expect(loadedState.nodes).toEqual(initialState.nodes); - expect(loadedState.edges).toEqual(initialState.edges); - }); - - test('Save uses showSaveFilePicker and writes JSON', async () => { - // Seed a simple graph so Save has something to write - act(() => { - useFlowStore.getState().setNodes([ - { id: 'start', type: 'start', position: { x: 0, y: 0 }, data: { label: 'start' } } as any, - { id: 'phase-1', type: 'phase', position: { x: 100, y: 120 }, data: { label: 'P1', number: 1 } } as any, - { id: 'end', type: 'end', position: { x: 0, y: 300 }, data: { label: 'End' } } as any, - ]); - useFlowStore.getState().setEdges([ - { id: 'start-phase-1', source: 'start', target: 'phase-1' } as any, - ]); - }); - - // capture what the app writes; don't decode inside the spy - let writtenChunk: any = null; - const write = jest.fn(async (chunk: any) => { writtenChunk = chunk; }); - const close = jest.fn().mockResolvedValue(undefined); - const createWritable = jest.fn().mockResolvedValue({ write, close }); - - // Mock the picker - (window as any).showSaveFilePicker = jest.fn().mockResolvedValue({ createWritable }); - - render(); - - await act(async () => { - fireEvent.click(screen.getByText(/Save Graph/i)); - }); - // @ts-expect-error - expect(window.showSaveFilePicker).toHaveBeenCalledTimes(1); - expect(createWritable).toHaveBeenCalledTimes(1); - expect(write).toHaveBeenCalledTimes(1); - expect(close).toHaveBeenCalledTimes(1); - - const writtenText = await chunkToString(writtenChunk); - const json = JSON.parse(writtenText); - expect(json.version).toBe(1); - expect(json.name).toBeDefined(); - expect(Array.isArray(json.nodes)).toBe(true); - expect(Array.isArray(json.edges)).toBe(true); - expect(json.behaviorProgram).toBeUndefined(); - }); - - test('Save falls back to anchor download when picker unavailable', async () => { - // Remove picker so we hit the fallback - delete (window as any).showSaveFilePicker; - - // Keep a reference to the REAL createElement to avoid recursion - const realCreateElement = document.createElement.bind(document); - - // Spy on URL + anchor click - const origCreateObjectURL = URL.createObjectURL; - const origRevokeObjectURL = URL.revokeObjectURL; - (URL as any).createObjectURL = jest.fn(() => 'blob:fake-url'); - (URL as any).revokeObjectURL = jest.fn(); - - const clickSpy = jest.fn(); - const createElementSpy = jest - .spyOn(document, 'createElement') - .mockImplementation((tag: any, opts?: any) => { - if (tag === 'a') { - // return a minimal anchor with a click spy - return { - set href(_v: string) {}, - set download(_v: string) {}, - click: clickSpy, - } as unknown as HTMLAnchorElement; - } - // call the REAL createElement for everything else - return realCreateElement(tag, opts as any); - }); - - render(); - - await act(async () => { - fireEvent.click(screen.getByText(/Save Graph/i)); - }); - - expect(URL.createObjectURL).toHaveBeenCalledTimes(1); - expect(clickSpy).toHaveBeenCalledTimes(1); - - // cleanup - createElementSpy.mockRestore(); - (URL as any).createObjectURL = origCreateObjectURL; - (URL as any).revokeObjectURL = origRevokeObjectURL; - }); -}); - -// -// helpers -// - -// portable blob reader (no Response needed) +// helper to read Blob contents in tests (works in Node/Jest env) async function blobToText(blob: Blob): Promise { - const anyBlob = blob as any; - if (typeof anyBlob.text === 'function') return anyBlob.text(); - if (typeof anyBlob.arrayBuffer === 'function') { - const buf = await anyBlob.arrayBuffer(); + if (typeof (blob as any).text === "function") return await (blob as any).text(); + if (typeof (blob as any).arrayBuffer === "function") { + const buf = await (blob as any).arrayBuffer(); return new TextDecoder().decode(buf); } return await new Promise((resolve, reject) => { @@ -158,15 +20,135 @@ async function blobToText(blob: Blob): Promise { }); } -// normalize whatever chunk createWritable.write receives to a string -async function chunkToString(chunk: any): Promise { - if (typeof chunk === 'string') return chunk; - if (chunk instanceof Blob) return blobToText(chunk); - if (chunk?.buffer instanceof ArrayBuffer) { - return new TextDecoder().decode(chunk as Uint8Array); - } - if (chunk instanceof ArrayBuffer) { - return new TextDecoder().decode(new Uint8Array(chunk)); - } - return String(chunk); -} +beforeAll(() => { + // if you have a mockReactFlow helper used in other tests, call it + if (typeof mockReactFlow === "function") mockReactFlow(); +}); + +beforeEach(() => { + // clear and seed the zustand store to a known empty state + act(() => { + const { setNodes, setEdges } = useFlowStore.getState(); + setNodes([]); + setEdges([]); + }); + + // Ensure URL.createObjectURL exists so jest.spyOn works + if (!URL.createObjectURL) URL.createObjectURL = jest.fn(); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +describe("SaveLoadPanel - combined tests", () => { + test("makeProjectBlob creates a valid JSON blob", async () => { + const nodes = [ + { + id: "n1", + type: "start", + position: { x: 0, y: 0 }, + data: { label: "Start" }, + } as any, + ]; + const edges: any[] = []; + + const blob = makeProjectBlob("my-project", nodes, edges); + expect(blob).toBeInstanceOf(Blob); + + const text = await blobToText(blob); + const parsed = JSON.parse(text); + + expect(parsed.name).toBe("my-project"); + expect(typeof parsed.savedAt).toBe("string"); + expect(Array.isArray(parsed.nodes)).toBe(true); + expect(Array.isArray(parsed.edges)).toBe(true); + expect(parsed.nodes).toEqual(nodes); + expect(parsed.edges).toEqual(edges); + }); + + test("onSave creates a blob URL and sets anchor href", async () => { + // Seed the store so onSave has nodes to save + act(() => { + useFlowStore.getState().setNodes([ + { id: "start", type: "start", position: { x: 0, y: 0 }, data: { label: "start" } } as any, + ]); + useFlowStore.getState().setEdges([]); + }); + + // Ensure createObjectURL exists and spy it + if (!URL.createObjectURL) URL.createObjectURL = jest.fn(); + const createObjectURLSpy = jest.spyOn(URL, "createObjectURL").mockReturnValue("blob:fake-url"); + + render(); + + const saveAnchor = screen.getByText(/Save Graph/i) as HTMLAnchorElement; + + await act(async () => { + fireEvent.click(saveAnchor); + }); + + expect(createObjectURLSpy).toHaveBeenCalledTimes(1); + const blobArg = createObjectURLSpy.mock.calls[0][0]; + expect(blobArg).toBeInstanceOf(Blob); + + expect(saveAnchor.getAttribute("href")).toBe("blob:fake-url"); + + const text = await blobToText(blobArg as Blob); + const parsed = JSON.parse(text); + + expect(parsed.name).toBeDefined(); + expect(parsed.nodes).toBeDefined(); + expect(parsed.edges).toBeDefined(); + + createObjectURLSpy.mockRestore(); + }); + + test("onLoad with invalid JSON does not update store", async () => { + const file = new File(["not json"], "bad.json", { type: "application/json" }); + + render(); + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + expect(input).toBeTruthy(); + + // Click Load to install the resolver + const loadButton = screen.getByRole("button", { name: /load graph/i }); + + // Do click and change inside same act to ensure resolver is set + await act(async () => { + fireEvent.click(loadButton); + fireEvent.change(input, { target: { files: [file] } }); + await Promise.resolve(); + }); + + await waitFor(() => { + const nodesAfter = useFlowStore.getState().nodes; + expect(nodesAfter).toHaveLength(0); + expect(input.value).toBe(""); + }); + }); + + test("onLoad resolves to null when no file is chosen (user cancels) and does not update store", async () => { + render(); + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + expect(input).toBeTruthy(); + + // Click Load to set resolver + const loadButton = screen.getByRole("button", { name: /load graph/i }); + + await act(async () => { + fireEvent.click(loadButton); + // simulate user cancelling: change with empty files + fireEvent.change(input, { target: { files: [] } }); + await Promise.resolve(); + }); + + await waitFor(() => { + const nodesAfter = useFlowStore.getState().nodes; + const edgesAfter = useFlowStore.getState().edges; + expect(nodesAfter).toHaveLength(0); + expect(edgesAfter).toHaveLength(0); + expect(input.value).toBe(""); + }); + }); +}); -- 2.49.1 From 95397ceccce111af897c0bef6d9ba075fc20d839 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Thu, 4 Dec 2025 12:33:27 +0100 Subject: [PATCH 130/184] fix: fix the tests by simulating user actions rather than the function, and avoid the cyclic dependancy which was present ref: N25B-371 --- .../visualProgrammingUI/NodeRegistry.ts | 2 - .../components/DragDropSidebar.tsx | 42 +++++++++- .../visualProgrammingUI/nodes/PhaseNode.tsx | 1 - .../visualProgrammingUI/utils/AddNode.ts | 39 ---------- .../nodes/PhaseNode.test.tsx | 78 +++++++++++++++++-- 5 files changed, 111 insertions(+), 51 deletions(-) delete mode 100644 src/pages/VisProgPage/visualProgrammingUI/utils/AddNode.ts diff --git a/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts b/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts index 8812434..84b0ec5 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts @@ -79,7 +79,6 @@ export const NodeConnects = { export const NodeDeletes = { start: () => false, end: () => false, - test: () => false, // Used for coverage of universal/ undefined nodes } /** @@ -92,5 +91,4 @@ export const NodesInPhase = { start: () => false, end: () => false, phase: () => false, - test: () => false, // Used for coverage of universal/ undefined nodes } \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx index 1d4d931..8440552 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx @@ -1,9 +1,9 @@ import { useDraggable } from '@neodrag/react'; import { useReactFlow, type XYPosition } from '@xyflow/react'; import { type ReactNode, useCallback, useRef, useState } from 'react'; +import useFlowStore from '../VisProgStores'; import styles from '../../VisProg.module.css'; import { NodeDefaults, type NodeTypes } from '../NodeRegistry' -import addNode from '../utils/AddNode'; /** * Props for a draggable node within the drag-and-drop toolbar. @@ -57,6 +57,46 @@ function DraggableNode({ className, children, nodeType, onDrop }: DraggableNodeP ); } +/** + * Adds a new node to the flow graph. + * + * Handles: + * - Automatic node ID generation based on existing nodes of the same type. + * - Loading of default data from the `NodeDefaults` registry. + * - Integration with the flow store to update global node state. + * + * @param nodeType - The type of node to create (from `NodeTypes`). + * @param position - The XY position in the flow canvas where the node will appear. + */ +function addNode(nodeType: keyof typeof NodeTypes, position: XYPosition) { + const { nodes, setNodes } = useFlowStore.getState(); + + // Load any predefined data for this node type. + const defaultData = NodeDefaults[nodeType] ?? {} + + // Currently, we find out what the Id is by checking the last node and adding one. + const sameTypeNodes = nodes.filter((node) => node.type === nodeType); + const nextNumber = + sameTypeNodes.length > 0 + ? (() => { + const lastNode = sameTypeNodes[sameTypeNodes.length - 1]; + const parts = lastNode.id.split('-'); + const lastNum = Number(parts[1]); + return Number.isNaN(lastNum) ? sameTypeNodes.length + 1 : lastNum + 1; + })() + : 1; + const id = `${nodeType}-${nextNumber}`; + + // Create new node + const newNode = { + id: id, + type: nodeType, + position, + data: JSON.parse(JSON.stringify(defaultData)) + } + setNodes([...nodes, newNode]); +} + /** * The drag-and-drop toolbar component for the visual programming interface. * diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx index 56c762c..c8ea2c0 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx @@ -110,7 +110,6 @@ export function PhaseReduce(node: Node, nodes: Node[]) { * @param isThisSource whether this instance of the node was the source in the connection, true = yes. */ export function PhaseConnects(thisNode: Node, otherNode: Node, isThisSource: boolean) { - console.log("Connect functionality called.") const node = thisNode as PhaseNode const data = node.data as PhaseNodeData if (!isThisSource) diff --git a/src/pages/VisProgPage/visualProgrammingUI/utils/AddNode.ts b/src/pages/VisProgPage/visualProgrammingUI/utils/AddNode.ts deleted file mode 100644 index b73d46b..0000000 --- a/src/pages/VisProgPage/visualProgrammingUI/utils/AddNode.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { XYPosition } from "@xyflow/react"; -import { NodeDefaults, type NodeTypes } from "../NodeRegistry"; -import useFlowStore from "../VisProgStores"; - - -/** - * addNode — adds a new node to the flow using the unified class-based system. - * Keeps numbering logic for phase/norm nodes. - */ -export default function addNode(nodeType: keyof typeof NodeTypes, position: XYPosition) { - const { nodes, setNodes } = useFlowStore.getState(); - - // Find out if there's any default data about our ndoe - const defaultData = NodeDefaults[nodeType] ?? {} - - // Currently, we find out what the Id is by checking the last node and adding one - const sameTypeNodes = nodes.filter((node) => node.type === nodeType); - const nextNumber = - sameTypeNodes.length > 0 - ? (() => { - const lastNode = sameTypeNodes[sameTypeNodes.length - 1]; - const parts = lastNode.id.split('-'); - const lastNum = Number(parts[1]); - return Number.isNaN(lastNum) ? sameTypeNodes.length + 1 : lastNum + 1; - })() - : 1; - const id = `${nodeType}-${nextNumber}`; - - // Create new node - const newNode = { - id: id, - type: nodeType, - position, - // Deep copy using JSON because thats how things work: - // Ref: https://developer.mozilla.org/en-US/docs/Glossary/Deep_copy - data: JSON.parse(JSON.stringify(defaultData)) - } - setNodes([...nodes, newNode]); -} \ No newline at end of file diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/PhaseNode.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/PhaseNode.test.tsx index 006380c..01de131 100644 --- a/test/pages/visProgPage/visualProgrammingUI/nodes/PhaseNode.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/PhaseNode.test.tsx @@ -1,16 +1,78 @@ import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores'; -import addNode from "../../../../../src/pages/VisProgPage/visualProgrammingUI/utils/AddNode"; import type { PhaseNodeData } from "../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode"; +import { getByTestId, render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import VisProgPage from '../../../../../src/pages/VisProgPage/VisProg'; +class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} +window.ResizeObserver = ResizeObserver; + +jest.mock('@neodrag/react', () => ({ + useDraggable: (ref: React.RefObject, options: any) => { + // We access the real useEffect from React to attach a listener + // This bridges the gap between the test's userEvent and the component's logic + const { useEffect } = jest.requireActual('react'); + + useEffect(() => { + const element = ref.current; + if (!element) return; + + // When the test fires a "pointerup" (end of click/drag), + // we manually trigger the library's onDragEnd callback. + const handlePointerUp = (e: PointerEvent) => { + if (options.onDragEnd) { + options.onDragEnd({ event: e }); + } + }; + + element.addEventListener('pointerup', handlePointerUp as EventListener); + return () => { + element.removeEventListener('pointerup', handlePointerUp as EventListener); + }; + }, [ref, options]); + }, +})); + describe('PhaseNode', () => { - it('each created phase gets its own children array, not the same reference ', () => { - // Create nodes - addNode("phase", {x:10,y:10}) - addNode("phase", {x:20,y:20}) - addNode("norm", {x:30,y:30}) - addNode("norm", {x:40,y:40}) - addNode("goal", {x:50,y:50}) + it('each created phase gets its own children array, not the same reference ', async () => { + const user = userEvent.setup(); + + const { container } = render(); + + // --- Mock ReactFlow bounding box --- + // Your DndToolbar checks these values: + const flowEl = container.querySelector('.react-flow'); + jest.spyOn(flowEl!, 'getBoundingClientRect').mockReturnValue({ + left: 0, + right: 800, + top: 0, + bottom: 600, + width: 800, + height: 600, + x: 0, + y: 0, + toJSON: () => {}, + }); + + // Find the draggable norm node in the toolbar + const phaseButton = getByTestId(container, 'draggable-phase') + + // Simulate dropping phase down in graph (twice) + for (let i = 0; i < 2; i++) { + await user.pointer([ + // touch the screen at element1 + {keys: '[TouchA>]', target: phaseButton}, + // move the touch pointer to element2 + {pointerName: 'TouchA', coords: {x: 300, y: 250}}, + // release the touch pointer at the last position (element2) + {keys: '[/TouchA]'}, + ]); + } // Find nodes const nodes = useFlowStore.getState().nodes; -- 2.49.1 From 1bfcfc04587b262931c7e4080d0a92760f341a72 Mon Sep 17 00:00:00 2001 From: Twirre Meulenbelt <43213592+TwirreM@users.noreply.github.com> Date: Thu, 4 Dec 2025 12:55:36 +0100 Subject: [PATCH 131/184] feat: use input element directly Previously, a button proxy was used which required the use of complicated reference management. Using the HTML `input` element directly simplifies the implementation. Also moved some styles. ref: N25B-189 --- src/index.css | 1 - src/pages/VisProgPage/VisProg.module.css | 28 ----- .../components/SaveLoadPanel.module.css | 30 ++++++ .../components/SaveLoadPanel.tsx | 100 +++++------------- .../components/SaveLoadPanel.test.tsx | 16 +-- 5 files changed, 66 insertions(+), 109 deletions(-) create mode 100644 src/pages/VisProgPage/visualProgrammingUI/components/SaveLoadPanel.module.css diff --git a/src/index.css b/src/index.css index 986e666..6e28fe5 100644 --- a/src/index.css +++ b/src/index.css @@ -26,7 +26,6 @@ html, body, #root { } a { - font-weight: 500; color: canvastext; } diff --git a/src/pages/VisProgPage/VisProg.module.css b/src/pages/VisProgPage/VisProg.module.css index df38224..5f2aa78 100644 --- a/src/pages/VisProgPage/VisProg.module.css +++ b/src/pages/VisProgPage/VisProg.module.css @@ -26,14 +26,6 @@ align-items: center; } -.save-load-panel { - outline: 2.5pt solid black; - border-radius: 0 0 5pt 5pt; - border-color: dimgrey; - background-color: canvas; - align-items: center; -} - .dnd-node-container { background-color: canvas; justify-content: center; @@ -134,23 +126,3 @@ outline: red solid 2pt; filter: drop-shadow(0 0 0.25rem red); } - -.save-button-like { - padding: 3px 10px; - background-color: canvas; - border-radius: 5pt; - outline: dodgerblue solid 2pt; - filter: drop-shadow(0 0 0.25rem dodgerblue); -} - -a.save-button-like { - display: inline-block; - text-decoration: none; - color: inherit; - cursor: pointer; - transition: filter 200ms, background-color 200ms; -} - -a.save-button-like:hover { - filter: drop-shadow(0 0 0.5rem dodgerblue); -} \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/SaveLoadPanel.module.css b/src/pages/VisProgPage/visualProgrammingUI/components/SaveLoadPanel.module.css new file mode 100644 index 0000000..9dbafa2 --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/components/SaveLoadPanel.module.css @@ -0,0 +1,30 @@ +.save-load-panel { + border-radius: 0 0 5pt 5pt; + background-color: canvas; +} + +label.file-input-button { + cursor: pointer; + outline: forestgreen solid 2pt; + filter: drop-shadow(0 0 0.25rem forestgreen); + transition: filter 200ms; + + input[type="file"] { + display: none; + } + + &:hover { + filter: drop-shadow(0 0 0.5rem forestgreen); + } +} + +.save-button { + text-decoration: none; + outline: dodgerblue solid 2pt; + filter: drop-shadow(0 0 0.25rem dodgerblue); + transition: filter 200ms; + + &:hover { + filter: drop-shadow(0 0 0.5rem dodgerblue); + } +} diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/SaveLoadPanel.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/SaveLoadPanel.tsx index 7e34147..baac724 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/SaveLoadPanel.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/SaveLoadPanel.tsx @@ -1,7 +1,7 @@ -import { useRef, useState, useEffect } from "react"; +import {type ChangeEvent, useRef, useState} from "react"; import useFlowStore from "../VisProgStores"; -import styles from "../../VisProg.module.css"; -import { cleanup } from "@testing-library/react"; +import visProgStyles from "../../VisProg.module.css"; +import styles from "./SaveLoadPanel.module.css"; import { makeProjectBlob, type SavedProject } from "../../../../utils/SaveLoad"; export default function SaveLoadPanel() { @@ -14,99 +14,55 @@ export default function SaveLoadPanel() { // ref to the file input const inputRef = useRef(null); - // ref to hold the resolver for the currently pending load promise - const resolverRef = useRef<((p: SavedProject | null) => void) | null>(null); - useEffect(() => { - return () => { - if (resolverRef.current) { - resolverRef.current(null); - resolverRef.current = null; - } - }; - }, []); - - const onSave = async () => { - const nameGuess = "visual-program"; + const onSave = async (nameGuess = "visual-program") => { const blob = makeProjectBlob(nameGuess, nodes, edges); const url = URL.createObjectURL(blob); setSaveUrl(url); }; - const onLoad = async () => { + // input change handler updates the graph with a parsed JSON file + const handleFileChange = async (e: ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; try { - const proj = await new Promise((resolve) => { - resolverRef.current = resolve; - inputRef.current?.click(); - }); - // clear stored resolver - resolverRef.current = null; - - if (!proj) return; - - cleanup(); - setNodes(proj.nodes); - setEdges(proj.edges); + const text = await file.text(); + const parsed = JSON.parse(text) as SavedProject; + if (!parsed.nodes || !parsed.edges) throw new Error("Invalid file format"); + setNodes(parsed.nodes); + setEdges(parsed.edges); } catch (e) { console.error(e); alert("Loading failed. See console."); - } - }; - - // input change handler resolves the onLoad promise with parsed project or null - const handleFileChange = async (e: React.ChangeEvent) => { - try { - const file = e.target.files?.[0]; - if (!file) { - resolverRef.current?.(null); - resolverRef.current = null; - return; - } - try { - const text = await file.text(); - const parsed = JSON.parse(text) as SavedProject; - resolverRef.current?.(parsed ?? null); - } catch { - resolverRef.current?.(null); - } finally { - // allow re-selecting same file next time - if (inputRef.current) inputRef.current.value = ""; - resolverRef.current = null; - } - } catch { - resolverRef.current?.(null); - resolverRef.current = null; + } finally { + // allow re-selecting same file next time + if (inputRef.current) inputRef.current.value = ""; } }; const defaultName = "visual-program"; return ( -
    +
    You can save and load your graph here.
    - ); diff --git a/test/pages/visProgPage/visualProgrammingUI/components/SaveLoadPanel.test.tsx b/test/pages/visProgPage/visualProgrammingUI/components/SaveLoadPanel.test.tsx index 97bbf11..9d85323 100644 --- a/test/pages/visProgPage/visualProgrammingUI/components/SaveLoadPanel.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/components/SaveLoadPanel.test.tsx @@ -106,22 +106,22 @@ describe("SaveLoadPanel - combined tests", () => { test("onLoad with invalid JSON does not update store", async () => { const file = new File(["not json"], "bad.json", { type: "application/json" }); + file.text = jest.fn(() => Promise.resolve(`{"bad json`)); + + window.alert = jest.fn(); render(); const input = document.querySelector('input[type="file"]') as HTMLInputElement; expect(input).toBeTruthy(); - // Click Load to install the resolver - const loadButton = screen.getByRole("button", { name: /load graph/i }); - - // Do click and change inside same act to ensure resolver is set - await act(async () => { - fireEvent.click(loadButton); + // Give some input + act(() => { fireEvent.change(input, { target: { files: [file] } }); - await Promise.resolve(); }); await waitFor(() => { + expect(window.alert).toHaveBeenCalledTimes(1); + const nodesAfter = useFlowStore.getState().nodes; expect(nodesAfter).toHaveLength(0); expect(input.value).toBe(""); @@ -134,7 +134,7 @@ describe("SaveLoadPanel - combined tests", () => { expect(input).toBeTruthy(); // Click Load to set resolver - const loadButton = screen.getByRole("button", { name: /load graph/i }); + const loadButton = screen.getByLabelText(/load graph/i); await act(async () => { fireEvent.click(loadButton); -- 2.49.1 From 5e22ed8806ff1aafc0e1a5b36f652b28f30d4cd8 Mon Sep 17 00:00:00 2001 From: "Gerla, J. (Justin)" Date: Sun, 7 Dec 2025 15:21:59 +0000 Subject: [PATCH 132/184] feat: added undo and redo functionality --- src/components/TextField.tsx | 6 +- src/pages/VisProgPage/VisProg.tsx | 31 ++- .../visualProgrammingUI/EditorUndoRedo.ts | 129 ++++++++++ .../visualProgrammingUI/VisProgStores.tsx | 95 ++++--- .../visualProgrammingUI/VisProgTypes.tsx | 12 + .../components/DragDropSidebar.tsx | 8 +- .../visualProgrammingUI/nodes/GoalNode.tsx | 15 +- .../EditorUndoRedo.test.ts | 239 ++++++++++++++++++ test/setupFlowTests.ts | 6 + 9 files changed, 490 insertions(+), 51 deletions(-) create mode 100644 src/pages/VisProgPage/visualProgrammingUI/EditorUndoRedo.ts create mode 100644 test/pages/visProgPage/visualProgrammingUI/EditorUndoRedo.test.ts diff --git a/src/components/TextField.tsx b/src/components/TextField.tsx index f9527c8..6dbc47b 100644 --- a/src/components/TextField.tsx +++ b/src/components/TextField.tsx @@ -1,4 +1,4 @@ -import {useState} from "react"; +import {useEffect, useState} from "react"; import styles from "./TextField.module.css"; /** @@ -105,6 +105,10 @@ export function TextField({ }) { const [inputValue, setInputValue] = useState(value); + useEffect(() => { + setInputValue(value); + }, [value]); + const onCommit = () => setValue(inputValue); return ({ onConnect: state.onConnect, onReconnectStart: state.onReconnectStart, onReconnectEnd: state.onReconnectEnd, - onReconnect: state.onReconnect + onReconnect: state.onReconnect, + undo: state.undo, + redo: state.redo, + beginBatchAction: state.beginBatchAction, + endBatchAction: state.endBatchAction }); // --| define ReactFlow editor |-- @@ -60,9 +65,23 @@ const VisProgUI = () => { onConnect, onReconnect, onReconnectStart, - onReconnectEnd + onReconnectEnd, + undo, + redo, + beginBatchAction, + endBatchAction } = useFlowStore(useShallow(selector)); // instructs the editor to use the corresponding functions from the FlowStore + // adds ctrl+z and ctrl+y support to respectively undo and redo actions + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.ctrlKey && e.key === 'z') undo(); + if (e.ctrlKey && e.key === 'y') redo(); + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }); + return (
    { onReconnectStart={onReconnectStart} onReconnectEnd={onReconnectEnd} onConnect={onConnect} + onNodeDragStart={beginBatchAction} + onNodeDragStop={endBatchAction} snapToGrid fitView proOptions={{hideAttribution: true}} @@ -83,6 +104,10 @@ const VisProgUI = () => { {/* contains the drag and drop panel for nodes */} + + + + @@ -90,8 +115,6 @@ const VisProgUI = () => { ); }; - - /** * Places the VisProgUI component inside a ReactFlowProvider * diff --git a/src/pages/VisProgPage/visualProgrammingUI/EditorUndoRedo.ts b/src/pages/VisProgPage/visualProgrammingUI/EditorUndoRedo.ts new file mode 100644 index 0000000..70c4c01 --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/EditorUndoRedo.ts @@ -0,0 +1,129 @@ +import type {Edge, Node} from "@xyflow/react"; +import type {StateCreator, StoreApi } from 'zustand/vanilla'; +import type {FlowState} from "./VisProgTypes.tsx"; + +export type FlowSnapshot = { + nodes: Node[]; + edges: Edge[]; +} + +/** + * A reduced version of the flowState type, + * This removes the functions that are provided by UndoRedo from the expected input type + */ +type BaseFlowState = Omit; + + +/** + * UndoRedo is implemented as a middleware for the FlowState store, + * this allows us to keep the undo redo logic separate from the flowState, + * and thus from the internal editor logic + * + * Allows users to undo and redo actions in the visual programming editor + * + * @param {(set: StoreApi["setState"], get: () => FlowState, api: StoreApi) => BaseFlowState} config + * @returns {StateCreator} + * @constructor + */ +export const UndoRedo = ( + config: ( + set: StoreApi['setState'], + get: () => FlowState, + api: StoreApi + ) => BaseFlowState ) : StateCreator => (set, get, api) => { + let batchTimeout: number | null = null; + + /** + * Captures the current state for + * + * @param {BaseFlowState} state - the current state of the editor + * @returns {FlowSnapshot} - returns a snapshot of the current editor state + */ + const getSnapshot = (state : BaseFlowState) : FlowSnapshot => ({ + nodes: state.nodes, + edges: state.edges + }); + + const initialState = config(set, get, api); + + return { + ...initialState, + + /** + * Adds a snapshot of the current state to the undo history + */ + pushSnapshot: () => { + const state = get(); + // we don't add new snapshots during an ongoing batch action + if (!state.isBatchAction) { + set({ + past: [...state.past, getSnapshot(state)], + future: [] + }); + } + + }, + + /** + * Undoes the last action from the editor, + * The state before undoing is added to the future for potential redoing + */ + undo: () => { + const state = get(); + if (!state.past.length) return; + + const snapshot = state.past.pop()!; // pop last snapshot + const currentSnapshot: FlowSnapshot = getSnapshot(state); + + set({ + nodes: snapshot.nodes, + edges: snapshot.edges, + }); + + state.future.push(currentSnapshot); // push current to redo + }, + + /** + * redoes the last undone action, + * The state before redoing is added to the past for potential undoing + */ + redo: () => { + const state = get(); + if (!state.future.length) return; + + const snapshot = state.future.pop()!; // pop last redo + const currentSnapshot: FlowSnapshot = getSnapshot(state); + + set({ + nodes: snapshot.nodes, + edges: snapshot.edges, + }); + + state.past.push(currentSnapshot); // push current to undo + }, + + /** + * Begins a batched action + * + * An example of a batched action is dragging a node in the editor, + * where we want the entire action of moving a node to a different position + * to be covered by one undoable snapshot + */ + beginBatchAction: () => { + get().pushSnapshot(); + set({ isBatchAction: true }); + if (batchTimeout) clearTimeout(batchTimeout); + }, + + /** + * Ends a batched action, + * a very short timeout is used to prevent new snapshots from being added + * until we are certain that the batch event is finished + */ + endBatchAction: () => { + batchTimeout = window.setTimeout(() => { + set({ isBatchAction: false }); + }, 10); + } + } +} \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx index e79715f..5bcd855 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx @@ -10,6 +10,7 @@ import { } from '@xyflow/react'; import type { FlowState } from './VisProgTypes'; import { NodeDefaults, NodeConnects, NodeDeletes } from './NodeRegistry'; +import { UndoRedo } from "./EditorUndoRedo.ts"; /** @@ -34,7 +35,7 @@ function createNode(id: string, type: string, position: XYPosition, data: Record return {...defaultData, ...newData} } -//* Initial nodes to populate the flow at startup. +//* Initial nodes, created by using createNode. */ const initialNodes : Node[] = [ createNode('start', 'start', {x: 100, y: 100}, {label: "Start"}, false), createNode('end', 'end', {x: 500, y: 100}, {label: "End"}, false), @@ -42,7 +43,7 @@ const initialNodes : Node[] = [ createNode('norms-1', 'norm', {x:-200, y:100}, {label: "Initial Norms", normList: ["Be a robot", "get good"]}), ]; -//* Initial edges to connect the startup nodes. +// * Initial edges * / const initialEdges: Edge[] = [ { id: 'start-phase-1', source: 'start', target: 'phase-1' }, { id: 'phase-1-end', source: 'phase-1', target: 'end' }, @@ -50,17 +51,17 @@ const initialEdges: Edge[] = [ /** - * How we have defined the functions for our FlowState. - * We have the normal functionality of a default FlowState with some exceptions to account for extra functionality. - * The biggest changes are in onConnect and onDelete, which we have given extra functionality based on the nodes defined functions. - * + * useFlowStore contains the implementation for all editor functionality + * and stores the current state of the visual programming editor + * * * Provides: * - Node and edge state management * - Node creation, deletion, and updates * - Custom connection handling via NodeConnects * - Edge reconnection handling + * - Undo Redo functionality through custom middleware */ -const useFlowStore = create((set, get) => ({ +const useFlowStore = create(UndoRedo((set, get) => ({ nodes: initialNodes, edges: initialEdges, edgeReconnectSuccessful: true, @@ -68,8 +69,7 @@ const useFlowStore = create((set, get) => ({ /** * Handles changes to nodes triggered by ReactFlow. */ - onNodesChange: (changes) => - set({nodes: applyNodeChanges(changes, get().nodes)}), + onNodesChange: (changes) => set({nodes: applyNodeChanges(changes, get().nodes)}), /** * Handles changes to edges triggered by ReactFlow. @@ -81,28 +81,34 @@ const useFlowStore = create((set, get) => ({ * Updates edges and calls the node-specific connection functions. */ onConnect: (connection) => { - const edges = addEdge(connection, get().edges); - const nodes = get().nodes; - // connection has: { source, sourceHandle, target, targetHandle } - // Let's find the source and target ID's. - const sourceNode = nodes.find((n) => n.id == connection.source); - const targetNode = nodes.find((n) => n.id == connection.target); - - // In case the nodes weren't found, return basic functionality. - if (sourceNode == undefined || targetNode == undefined || sourceNode.type == undefined || targetNode.type == undefined) { - set({ nodes, edges }); - return; - } + get().pushSnapshot(); - // We should find out how their data changes by calling their respective functions. - const sourceConnectFunction = NodeConnects[sourceNode.type as keyof typeof NodeConnects] - const targetConnectFunction = NodeConnects[targetNode.type as keyof typeof NodeConnects] - - // We're going to have to update their data based on how they want to update it. - sourceConnectFunction(sourceNode, targetNode, true) - targetConnectFunction(targetNode, sourceNode, false) - set({ nodes, edges }); -}, + const edges = addEdge(connection, get().edges); + const nodes = get().nodes; + // connection has: { source, sourceHandle, target, targetHandle } + // Let's find the source and target ID's. + const sourceNode = nodes.find((n) => n.id == connection.source); + const targetNode = nodes.find((n) => n.id == connection.target); + + // In case the nodes weren't found, return basic functionality. + if ( sourceNode == undefined + || targetNode == undefined + || sourceNode.type == undefined + || targetNode.type == undefined + ){ + set({ nodes, edges }); + return; + } + + // We should find out how their data changes by calling their respective functions. + const sourceConnectFunction = NodeConnects[sourceNode.type as keyof typeof NodeConnects] + const targetConnectFunction = NodeConnects[targetNode.type as keyof typeof NodeConnects] + + // We're going to have to update their data based on how they want to update it. + sourceConnectFunction(sourceNode, targetNode, true) + targetConnectFunction(targetNode, sourceNode, false) + set({ nodes, edges }); + }, /** * Handles reconnecting an edge between nodes. @@ -112,19 +118,32 @@ const useFlowStore = create((set, get) => ({ set({ edges: reconnectEdge(oldEdge, newConnection, get().edges) }); }, - onReconnectStart: () => set({ edgeReconnectSuccessful: false }), + onReconnectStart: () => { + get().pushSnapshot(); + set({ edgeReconnectSuccessful: false }) + }, + + /** + * handles potential dropping (deleting) of an edge + * if it is not reconnected to a node after detaching it + * + * @param _evt - the event + * @param {{id: string}} edge - the described edge + */ onReconnectEnd: (_evt, edge) => { if (!get().edgeReconnectSuccessful) { set({ edges: get().edges.filter((e) => e.id !== edge.id) }); } set({ edgeReconnectSuccessful: true }); }, - + /** * Deletes a node by ID, respecting NodeDeletes rules. * Also removes all edges connected to that node. */ deleteNode: (nodeId) => { + get().pushSnapshot(); + // Let's find our node to check if they have a special deletion function const ourNode = get().nodes.find((n)=>n.id==nodeId); const ourFunction = Object.entries(NodeDeletes).find(([t])=>t==ourNode?.type)?.[1] @@ -135,7 +154,7 @@ const useFlowStore = create((set, get) => ({ nodes: get().nodes.filter((n) => n.id !== nodeId), edges: get().edges.filter((e) => e.source !== nodeId && e.target !== nodeId), })} - }, + }, /** * Replaces the entire nodes array in the store. @@ -151,6 +170,7 @@ const useFlowStore = create((set, get) => ({ * Updates the data of a node by merging new data with existing data. */ updateNodeData: (nodeId, data) => { + get().pushSnapshot(); set({ nodes: get().nodes.map((node) => { if (node.id === nodeId) { @@ -165,8 +185,15 @@ const useFlowStore = create((set, get) => ({ * Adds a new node to the flow store. */ addNode: (node: Node) => { + get().pushSnapshot(); set({ nodes: [...get().nodes, node] }); }, -})); + + // undo redo default values + past: [], + future: [], + isBatchAction: false, + })) +); export default useFlowStore; diff --git a/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx b/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx index e466bed..b35bbf2 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx @@ -1,6 +1,8 @@ // VisProgTypes.ts import type { Edge, OnNodesChange, OnEdgesChange, OnConnect, OnReconnect, Node } from '@xyflow/react'; import type { NodeTypes } from './NodeRegistry'; +import type {FlowSnapshot} from "./EditorUndoRedo.ts"; + /** * Type representing all registered node types. @@ -74,4 +76,14 @@ export type FlowState = { * @param node - the Node object to add */ addNode: (node: Node) => void; + + // UndoRedo Types + past: FlowSnapshot[]; + future: FlowSnapshot[]; + pushSnapshot: () => void; + isBatchAction: boolean; + beginBatchAction: () => void; + endBatchAction: () => void; + undo: () => void; + redo: () => void; }; diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx index 92f211c..94ce1dd 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx @@ -64,8 +64,8 @@ function DraggableNode({ className, children, nodeType, onDrop }: DraggableNodeP * @param nodeType - The type of node to create (from `NodeTypes`). * @param position - The XY position in the flow canvas where the node will appear. */ -function addNode(nodeType: keyof typeof NodeTypes, position: XYPosition) { - const { nodes, setNodes } = useFlowStore.getState(); +function addNodeToFlow(nodeType: keyof typeof NodeTypes, position: XYPosition) { + const { nodes, addNode } = useFlowStore.getState(); // Load any predefined data for this node type. const defaultData = NodeDefaults[nodeType] ?? {} @@ -90,7 +90,7 @@ function addNode(nodeType: keyof typeof NodeTypes, position: XYPosition) { position, data: {...defaultData} } - setNodes([...nodes, newNode]); + addNode(newNode); } /** @@ -125,7 +125,7 @@ export function DndToolbar() { if (isInFlow) { const position = screenToFlowPosition(screenPosition); - addNode(nodeType, position); + addNodeToFlow(nodeType, position); } }, [screenToFlowPosition], diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx index 5be666b..6168f32 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx @@ -32,23 +32,22 @@ export type GoalNode = Node * @param props NodeProps, like id, label, children * @returns React.JSX.Element */ -export default function GoalNode(props: NodeProps) { - const data = props.data +export default function GoalNode({id, data}: NodeProps) { const {updateNodeData} = useFlowStore(); - const text_input_id = `goal_${props.id}_text_input`; - const checkbox_id = `goal_${props.id}_checkbox`; + const text_input_id = `goal_${id}_text_input`; + const checkbox_id = `goal_${id}_checkbox`; const setDescription = (value: string) => { - updateNodeData(props.id, {...data, description: value}); + updateNodeData(id, {...data, description: value}); } const setAchieved = (value: boolean) => { - updateNodeData(props.id, {...data, achieved: value}); + updateNodeData(id, {...data, achieved: value}); } return <> - +
    @@ -64,7 +63,7 @@ export default function GoalNode(props: NodeProps) { setAchieved(e.target.checked)} />
    diff --git a/test/pages/visProgPage/visualProgrammingUI/EditorUndoRedo.test.ts b/test/pages/visProgPage/visualProgrammingUI/EditorUndoRedo.test.ts new file mode 100644 index 0000000..76e7e96 --- /dev/null +++ b/test/pages/visProgPage/visualProgrammingUI/EditorUndoRedo.test.ts @@ -0,0 +1,239 @@ +import {act} from '@testing-library/react'; +import useFlowStore from '../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx'; +import { mockReactFlow } from '../../../setupFlowTests.ts'; + + +beforeAll(() => { + mockReactFlow(); +}); + +describe("UndoRedo Middleware", () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + test("pushSnapshot adds a snapshot to past and clears future", () => { + const store = useFlowStore; + + store.setState({ + nodes: [{ + id: 'A', + type: 'default', + position: {x: 0, y: 0}, + data: {label: 'A'} + }], + edges: [], + past: [], + future: [{ + nodes: [ + { + id: 'A', + type: 'default', + position: {x: 0, y: 0}, + data: {label: 'A'} + }, + ], + edges: [] + }], + }); + + act(() => { + store.getState().pushSnapshot(); + }) + + const state = store.getState(); + expect(state.past.length).toBe(1); + expect(state.past[0]).toEqual({ + nodes: [{ + id: 'A', + type: 'default', + position: {x: 0, y: 0}, + data: {label: 'A'} + }], + edges: [] + }); + expect(state.future).toEqual([]); + }); + + test("pushSnapshot does nothing during batch action", () => { + const store = useFlowStore; + + act(() => { + store.setState({ isBatchAction: true }); + store.getState().pushSnapshot(); + }) + + expect(store.getState().past.length).toBe(0); + }); + + test("undo restores last snapshot and pushes current snapshot to future", () => { + const store = useFlowStore; + + // initial state + store.setState({ + nodes: [{ + id: 'A', + type: 'default', + position: {x: 0, y: 0}, + data: {label: 'A'} + }], + edges: [] + }); + + act(() => { + store.getState().pushSnapshot(); + + // modified state + store.setState({ + nodes: [{ + id: 'B', + type: 'default', + position: {x: 0, y: 0}, + data: {label: 'B'} + }], + edges: [] + }); + + store.getState().undo(); + }) + + expect(store.getState().nodes).toEqual([{ + id: 'A', + type: 'default', + position: {x: 0, y: 0}, + data: {label: 'A'} + }]); + expect(store.getState().future.length).toBe(1); + expect(store.getState().future[0]).toEqual({ + nodes: [{ + id: 'B', + type: 'default', + position: {x: 0, y: 0}, + data: {label: 'B'} + }], + edges: [] + }); + }); + + test("undo does nothing when past is empty", () => { + const store = useFlowStore; + + store.setState({past: []}); + + act(() => { store.getState().undo(); }); + + expect(store.getState().nodes).toEqual([]); + expect(store.getState().future).toEqual([]); + }); + + test("redo restores last future snapshot and pushes current to past", () => { + const store = useFlowStore; + + // initial + store.setState({ + nodes: [{ + id: 'A', + type: 'default', + position: {x: 0, y: 0}, + data: {label: 'A'} + }], + edges: [] + }); + + act(() => { + store.getState().pushSnapshot(); + store.setState({ + nodes: [{ + id: 'B', + type: 'default', + position: {x: 0, y: 0}, + data: {label: 'B'} + }], + edges: [] + }); + + + store.getState().undo(); + + // redo should restore node with id 'B' + store.getState().redo(); + }) + + expect(store.getState().nodes).toEqual([{ + id: 'B', + type: 'default', + position: {x: 0, y: 0}, + data: {label: 'B'} + }]); + expect(store.getState().past.length).toBe(1); // snapshot A stored + expect(store.getState().past[0]).toEqual({ + nodes: [{ + id: 'A', + type: 'default', + position: {x: 0, y: 0}, + data: {label: 'A'} + }], + edges: [] + }); + }); + + test("redo does nothing when future is empty", () => { + const store = useFlowStore; + + store.setState({past: []}); + act(() => { store.getState().redo(); }); + + expect(store.getState().nodes).toEqual([]); + }); + + test("beginBatchAction pushes snapshot and sets isBatchAction=true", () => { + const store = useFlowStore; + + store.setState({ + nodes: [{ + id: 'A', + type: 'default', + position: {x: 0, y: 0}, + data: {label: 'A'} + }], + edges: [] + }); + + act(() => { store.getState().beginBatchAction(); }); + + expect(store.getState().isBatchAction).toBe(true); + expect(store.getState().past.length).toBe(1); + }); + + test("endBatchAction sets isBatchAction=false after timeout", () => { + const store = useFlowStore; + + store.setState({ isBatchAction: true }); + act(() => { store.getState().endBatchAction(); }); + + // isBatchAction should remain true before the timer has advanced + expect(store.getState().isBatchAction).toBe(true); + + jest.advanceTimersByTime(10); + + // it should now be set to false as the timer has advanced enough + expect(store.getState().isBatchAction).toBe(false); + }); + + test("multiple beginBatchAction calls clear the timeout", () => { + const store = useFlowStore; + + act(() => { + store.getState().beginBatchAction(); + store.getState().endBatchAction(); // starts timeout + store.getState().beginBatchAction(); // should clear previous timeout + }); + + + jest.advanceTimersByTime(10); + + // After advancing the timers, isBatchAction should still be true, + // as the timeout should have been cleared + expect(store.getState().isBatchAction).toBe(true); + }); +}); diff --git a/test/setupFlowTests.ts b/test/setupFlowTests.ts index 21a4945..3ce8c3a 100644 --- a/test/setupFlowTests.ts +++ b/test/setupFlowTests.ts @@ -69,6 +69,9 @@ beforeAll(() => { useFlowStore.setState({ nodes: [], edges: [], + past: [], + future: [], + isBatchAction: false, edgeReconnectSuccessful: true }); }); @@ -78,6 +81,9 @@ afterEach(() => { useFlowStore.setState({ nodes: [], edges: [], + past: [], + future: [], + isBatchAction: false, edgeReconnectSuccessful: true }); }); -- 2.49.1 From 086caea7375749716802cbd12f54efea84e6fe9d Mon Sep 17 00:00:00 2001 From: JobvAlewijk Date: Sun, 7 Dec 2025 15:32:20 +0000 Subject: [PATCH 133/184] test: high coverage for all UI tests --- .../visualProgrammingUI/NodeRegistry.ts | 4 +- .../components/DragDropSidebar.tsx | 7 +- .../visualProgrammingUI/nodes/EndNode.tsx | 19 +- .../visualProgrammingUI/nodes/GoalNode.tsx | 19 +- .../visualProgrammingUI/nodes/NormNode.tsx | 20 +- .../visualProgrammingUI/nodes/PhaseNode.tsx | 6 +- .../visualProgrammingUI/nodes/StartNode.tsx | 19 +- .../visualProgrammingUI/nodes/TriggerNode.tsx | 24 +- test/components/Logging/Logging.test.tsx | 12 +- test/pages/robot/Robot.test.tsx | 167 ++++ .../components/DragDropSidebar.test.tsx | 109 ++- .../components/ScrollIntoView.test.tsx | 14 + .../nodes/NormNode.test.tsx | 744 ++++++++++++++++++ .../nodes/StartNode.test.tsx | 98 +++ .../nodes/TriggerNode.test.tsx | 246 ++++++ .../nodes/UniversalNodes.test.tsx | 151 ++++ test/test-utils/mocks.ts | 41 + test/test-utils/test-utils.tsx | 24 + 18 files changed, 1641 insertions(+), 83 deletions(-) create mode 100644 test/pages/robot/Robot.test.tsx create mode 100644 test/pages/visProgPage/visualProgrammingUI/components/ScrollIntoView.test.tsx create mode 100644 test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx create mode 100644 test/pages/visProgPage/visualProgrammingUI/nodes/StartNode.test.tsx create mode 100644 test/pages/visProgPage/visualProgrammingUI/nodes/TriggerNode.test.tsx create mode 100644 test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx create mode 100644 test/test-utils/mocks.ts create mode 100644 test/test-utils/test-utils.tsx diff --git a/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts b/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts index e64acc1..8812434 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts @@ -68,7 +68,7 @@ export const NodeConnects = { phase: PhaseConnects, norm: NormConnects, goal: GoalConnects, - trigger: TriggerConnects, + trigger: TriggerConnects, } /** @@ -79,6 +79,7 @@ export const NodeConnects = { export const NodeDeletes = { start: () => false, end: () => false, + test: () => false, // Used for coverage of universal/ undefined nodes } /** @@ -91,4 +92,5 @@ export const NodesInPhase = { start: () => false, end: () => false, phase: () => false, + test: () => false, // Used for coverage of universal/ undefined nodes } \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx index 94ce1dd..9a41f06 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx @@ -47,7 +47,11 @@ function DraggableNode({ className, children, nodeType, onDrop }: DraggableNodeP }); return ( -
    +
    {children}
    ); @@ -149,6 +153,7 @@ export function DndToolbar() { {/* Maps over all the nodes that are droppable, and puts them in the panel */} {droppableNodes.map(({type, data}) => ( ) { /** * Functionality for reducing this node into its more compact json program * @param node the node to reduce - * @param nodes all nodes present + * @param _nodes all nodes present * @returns Dictionary, {id: node.id} */ -export function EndReduce(node: Node, nodes: Node[]) { +export function EndReduce(node: Node, _nodes: Node[]) { // Replace this for nodes functionality - if (nodes.length <= -1) { - console.warn("Impossible nodes length in EndReduce") - } return { id: node.id } @@ -55,13 +52,9 @@ export function EndReduce(node: Node, nodes: Node[]) { /** * Any connection functionality that should get called when a connection is made to this node type (end) - * @param thisNode the node of which the functionality gets called - * @param otherNode the other node which has connected - * @param isThisSource whether this node is the one that is the source of the connection + * @param _thisNode the node of which the functionality gets called + * @param _otherNode the other node which has connected + * @param _isThisSource whether this node is the one that is the source of the connection */ -export function EndConnects(thisNode: Node, otherNode: Node, isThisSource: boolean) { - // Replace this for connection logic - if (thisNode == undefined && otherNode == undefined && isThisSource == false) { - console.warn("Impossible node connection called in EndConnects") - } +export function EndConnects(_thisNode: Node, _otherNode: Node, _isThisSource: boolean) { } \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx index 6168f32..bbacdf0 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx @@ -76,13 +76,9 @@ export default function GoalNode({id, data}: NodeProps) { /** * Reduces each Goal, including its children down into its relevant data. * @param node: The Node Properties of this node. - * @param nodes: all the nodes in the graph + * @param _nodes: all the nodes in the graph */ -export function GoalReduce(node: Node, nodes: Node[]) { - // Replace this for nodes functionality - if (nodes.length <= -1) { - console.warn("Impossible nodes length in GoalReduce") - } +export function GoalReduce(node: Node, _nodes: Node[]) { const data = node.data as GoalNodeData; return { id: node.id, @@ -94,13 +90,10 @@ export function GoalReduce(node: Node, nodes: Node[]) { /** * This function is called whenever a connection is made with this node type (Goal) - * @param thisNode the node of this node type which function is called - * @param otherNode the other node which was part of the connection - * @param isThisSource whether this instance of the node was the source in the connection, true = yes. + * @param _thisNode the node of this node type which function is called + * @param _otherNode the other node which was part of the connection + * @param _isThisSource whether this instance of the node was the source in the connection, true = yes. */ -export function GoalConnects(thisNode: Node, otherNode: Node, isThisSource: boolean) { +export function GoalConnects(_thisNode: Node, _otherNode: Node, _isThisSource: boolean) { // Replace this for connection logic - if (thisNode == undefined && otherNode == undefined && isThisSource == false) { - console.warn("Impossible node connection called in EndConnects") - } } \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx index d2ca50d..31d92a5 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx @@ -61,13 +61,9 @@ export default function NormNode(props: NodeProps) { /** * Reduces each Norm, including its children down into its relevant data. * @param node: The Node Properties of this node. - * @param nodes: all the nodes in the graph + * @param _nodes: all the nodes in the graph */ -export function NormReduce(node: Node, nodes: Node[]) { - // Replace this for nodes functionality - if (nodes.length <= -1) { - console.warn("Impossible nodes length in NormReduce") - } +export function NormReduce(node: Node, _nodes: Node[]) { const data = node.data as NormNodeData; return { id: node.id, @@ -78,13 +74,9 @@ export function NormReduce(node: Node, nodes: Node[]) { /** * This function is called whenever a connection is made with this node type (Norm) - * @param thisNode the node of this node type which function is called - * @param otherNode the other node which was part of the connection - * @param isThisSource whether this instance of the node was the source in the connection, true = yes. + * @param _thisNode the node of this node type which function is called + * @param _otherNode the other node which was part of the connection + * @param _isThisSource whether this instance of the node was the source in the connection, true = yes. */ -export function NormConnects(thisNode: Node, otherNode: Node, isThisSource: boolean) { - // Replace this for connection logic - if (thisNode == undefined && otherNode == undefined && isThisSource == false) { - console.warn("Impossible node connection called in EndConnects") - } +export function NormConnects(_thisNode: Node, _otherNode: Node, _isThisSource: boolean) { } \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx index 7234e34..56c762c 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx @@ -78,8 +78,10 @@ export function PhaseReduce(node: Node, nodes: Node[]) { .filter(([t]) => !nodesNotInPhase.includes(t)) .map(([t]) => t); - // children nodes - const childrenNodes = nodes.filter((node) => data.children.includes(node.id)); + // children nodes - make sure to check for empty arrays + let childrenNodes: Node[] = []; + if (data.children) + childrenNodes = nodes.filter((node) => data.children.includes(node.id)); // Build the result object const result: Record = { diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx index 6d74c08..f994090 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx @@ -40,14 +40,11 @@ export default function StartNode(props: NodeProps) { /** * The reduce function for this node type. * @param node this node - * @param nodes all the nodes in the graph + * @param _nodes all the nodes in the graph * @returns a reduced structure of this node */ -export function StartReduce(node: Node, nodes: Node[]) { +export function StartReduce(node: Node, _nodes: Node[]) { // Replace this for nodes functionality - if (nodes.length <= -1) { - console.warn("Impossible nodes length in StartReduce") - } return { id: node.id } @@ -55,13 +52,9 @@ export function StartReduce(node: Node, nodes: Node[]) { /** * This function is called whenever a connection is made with this node type (start) - * @param thisNode the node of this node type which function is called - * @param otherNode the other node which was part of the connection - * @param isThisSource whether this instance of the node was the source in the connection, true = yes. + * @param _thisNode the node of this node type which function is called + * @param _otherNode the other node which was part of the connection + * @param _isThisSource whether this instance of the node was the source in the connection, true = yes. */ -export function StartConnects(thisNode: Node, otherNode: Node, isThisSource: boolean) { - // Replace this for connection logic - if (thisNode == undefined && otherNode == undefined && isThisSource == false) { - console.warn("Impossible node connection called in EndConnects") - } +export function StartConnects(_thisNode: Node, _otherNode: Node, _isThisSource: boolean) { } \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx index 5c40aeb..2e7b732 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx @@ -80,14 +80,10 @@ export default function TriggerNode(props: NodeProps) { /** * Reduces each Trigger, including its children down into its core data. * @param node - The Trigger node to reduce. - * @param nodes - The list of all nodes in the current flow graph. + * @param _nodes - The list of all nodes in the current flow graph. * @returns A simplified object containing the node label and its list of triggers. */ -export function TriggerReduce(node: Node, nodes: Node[]) { - // Replace this for nodes functionality - if (nodes.length <= -1) { - console.warn("Impossible nodes length in TriggerReduce") - } +export function TriggerReduce(node: Node, _nodes: Node[]) { const data = node.data; switch (data.triggerType) { case "keywords": @@ -106,17 +102,13 @@ export function TriggerReduce(node: Node, nodes: Node[]) { } /** - * Handles logic that occurs when a connection is made involving a Trigger node. - * - * @param thisNode - The current Trigger node being connected. - * @param otherNode - The other node involved in the connection. - * @param isThisSource - Whether this node was the source of the connection. + * This function is called whenever a connection is made with this node type (trigger) + * @param _thisNode the node of this node type which function is called + * @param _otherNode the other node which was part of the connection + * @param _isThisSource whether this instance of the node was the source in the connection, true = yes. */ -export function TriggerConnects(thisNode: Node, otherNode: Node, isThisSource: boolean) { - // Replace this for connection logic - if (thisNode == undefined && otherNode == undefined && isThisSource == false) { - console.warn("Impossible node connection called in EndConnects") - } +export function TriggerConnects(_thisNode: Node, _otherNode: Node, _isThisSource: boolean) { + } // Definitions for the possible triggers, being keywords and emotions diff --git a/test/components/Logging/Logging.test.tsx b/test/components/Logging/Logging.test.tsx index 03d4a92..a3b6d09 100644 --- a/test/components/Logging/Logging.test.tsx +++ b/test/components/Logging/Logging.test.tsx @@ -127,10 +127,10 @@ describe("Logging component", () => { render(); - expect(screen.getByText("Logs")).toBeInTheDocument(); - expect(screen.getByText("WARNING")).toBeInTheDocument(); - expect(screen.getByText("logging")).toBeInTheDocument(); - expect(screen.getByText("Ping")).toBeInTheDocument(); + expect(screen.getByText("Logs")).toBeDefined(); + expect(screen.getByText("WARNING")).toBeDefined(); + expect(screen.getByText("logging")).toBeDefined(); + expect(screen.getByText("Ping")).toBeDefined(); let timestamp = screen.queryByText("ABS TIME"); if (!timestamp) { @@ -141,7 +141,7 @@ describe("Logging component", () => { } await user.click(timestamp); - expect(screen.getByText("00:00:12.345")).toBeInTheDocument(); + expect(screen.getByText("00:00:12.345")).toBeDefined(); }); it("shows the scroll-to-bottom button after a manual scroll and scrolls when clicked", async () => { @@ -188,7 +188,7 @@ describe("Logging component", () => { logCell.set({...current, message: "Updated"}); }); - expect(screen.getByText("Updated")).toBeInTheDocument(); + expect(screen.getByText("Updated")).toBeDefined(); await waitFor(() => { expect(scrollSpy).toHaveBeenCalledTimes(1); }); diff --git a/test/pages/robot/Robot.test.tsx b/test/pages/robot/Robot.test.tsx new file mode 100644 index 0000000..bcebac8 --- /dev/null +++ b/test/pages/robot/Robot.test.tsx @@ -0,0 +1,167 @@ +import { render, screen, act, cleanup, fireEvent } from '@testing-library/react'; +import Robot from '../../../src/pages/Robot/Robot'; + +// Mock EventSource +const mockInstances: MockEventSource[] = []; +class MockEventSource { + url: string; + onmessage: ((event: MessageEvent) => void) | null = null; + closed = false; + + constructor(url: string) { + this.url = url; + mockInstances.push(this); + } + + sendMessage(data: string) { + this.onmessage?.({ data } as MessageEvent); + } + + close() { + this.closed = true; + } +} + +// Mock global EventSource +beforeAll(() => { + (globalThis as any).EventSource = jest.fn((url: string) => new MockEventSource(url)); +}); + +// Mock fetch +beforeEach(() => { + globalThis.fetch = jest.fn(() => + Promise.resolve({ + json: () => Promise.resolve({ reply: 'ok' }), + }) + ) as jest.Mock; +}); + +// Cleanup +afterEach(() => { + cleanup(); + jest.restoreAllMocks(); + mockInstances.length = 0; +}); + +describe('Robot', () => { + test('renders initial state', () => { + render(); + expect(screen.getByText('Robot interaction')).toBeInTheDocument(); + expect(screen.getByText('Force robot speech')).toBeInTheDocument(); + expect(screen.getByText('Listening 🔴')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Enter a message')).toBeInTheDocument(); + }); + + test('sends message via button', async () => { + render(); + const input = screen.getByPlaceholderText('Enter a message'); + const button = screen.getByText('Speak'); + + fireEvent.change(input, { target: { value: 'Hello' } }); + await act(async () => fireEvent.click(button)); + + expect(globalThis.fetch).toHaveBeenCalledWith( + 'http://localhost:8000/message', + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message: 'Hello' }), + }) + ); + }); + + test('sends message via Enter key', async () => { + render(); + const input = screen.getByPlaceholderText('Enter a message'); + fireEvent.change(input, { target: { value: 'Hi Enter' } }); + + await act(async () => + fireEvent.keyDown(input, { key: 'Enter', code: 'Enter', charCode: 13 }) + ); + + expect(globalThis.fetch).toHaveBeenCalledWith( + 'http://localhost:8000/message', + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message: 'Hi Enter' }), + }) + ); + expect((input as HTMLInputElement).value).toBe(''); + }); + + test('handles fetch errors', async () => { + globalThis.fetch = jest.fn(() => Promise.reject('Network error')) as jest.Mock; + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + render(); + const input = screen.getByPlaceholderText('Enter a message'); + const button = screen.getByText('Speak'); + fireEvent.change(input, { target: { value: 'Error test' } }); + + await act(async () => fireEvent.click(button)); + + expect(consoleSpy).toHaveBeenCalledWith( + 'Error sending message: ', + 'Network error' + ); + }); + + test('updates conversation on SSE', async () => { + render(); + const eventSource = mockInstances[0]; + + await act(async () => { + eventSource.sendMessage(JSON.stringify({ voice_active: true })); + eventSource.sendMessage(JSON.stringify({ speech: 'User says hi' })); + eventSource.sendMessage(JSON.stringify({ llm_response: 'Assistant replies' })); + }); + + expect(screen.getByText('Listening 🟢')).toBeInTheDocument(); + expect(screen.getByText('User says hi')).toBeInTheDocument(); + expect(screen.getByText('Assistant replies')).toBeInTheDocument(); + }); + + test('handles invalid SSE JSON', async () => { + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + render(); + const eventSource = mockInstances[0]; + + await act(async () => eventSource.sendMessage('bad-json')); + + expect(logSpy).toHaveBeenCalledWith('Unparsable SSE message:', 'bad-json'); + }); + + test('resets conversation with Reset button', async () => { + render(); + const eventSource = mockInstances[0]; + + await act(async () => + eventSource.sendMessage(JSON.stringify({ speech: 'Hello' })) + ); + expect(screen.getByText('Hello')).toBeInTheDocument(); + + fireEvent.click(screen.getByText('Reset')); + expect(screen.queryByText('Hello')).not.toBeInTheDocument(); + }); + + test('toggles conversationIndex with Stop/Start button', () => { + render(); + const stopButton = screen.getByText('Stop'); + fireEvent.click(stopButton); + expect(screen.getByText('Start')).toBeInTheDocument(); + + fireEvent.click(screen.getByText('Start')); + expect(screen.getByText('Stop')).toBeInTheDocument(); + }); + + test('closes EventSource on unmount', () => { + const { unmount } = render(); + const eventSource = mockInstances[0]; + const closeSpy = jest.spyOn(eventSource, 'close'); + + unmount(); + expect(closeSpy).toHaveBeenCalled(); + expect(eventSource.closed).toBe(true); + }); +}); diff --git a/test/pages/visProgPage/visualProgrammingUI/components/DragDropSidebar.test.tsx b/test/pages/visProgPage/visualProgrammingUI/components/DragDropSidebar.test.tsx index 70087ee..486d41f 100644 --- a/test/pages/visProgPage/visualProgrammingUI/components/DragDropSidebar.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/components/DragDropSidebar.test.tsx @@ -1,5 +1,106 @@ -describe('Not implemented', () => { - test('nothing yet', () => { - expect(true) - }); +import { getByTestId, render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores'; +import VisProgPage from '../../../../../src/pages/VisProgPage/VisProg'; + + + +class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} +window.ResizeObserver = ResizeObserver; + +jest.mock('@neodrag/react', () => ({ + useDraggable: (ref: React.RefObject, options: any) => { + // We access the real useEffect from React to attach a listener + // This bridges the gap between the test's userEvent and the component's logic + const { useEffect } = jest.requireActual('react'); + + useEffect(() => { + const element = ref.current; + if (!element) return; + + // When the test fires a "pointerup" (end of click/drag), + // we manually trigger the library's onDragEnd callback. + const handlePointerUp = (e: PointerEvent) => { + if (options.onDragEnd) { + options.onDragEnd({ event: e }); + } + }; + + element.addEventListener('pointerup', handlePointerUp as EventListener); + return () => { + element.removeEventListener('pointerup', handlePointerUp as EventListener); + }; + }, [ref, options]); + }, +})); + +// We will mock @xyflow/react so we control screenToFlowPosition +jest.mock('@xyflow/react', () => { + const actual = jest.requireActual('@xyflow/react'); + return { + ...actual, + useReactFlow: () => ({ + screenToFlowPosition: ({ x, y }: { x: number; y: number }) => ({ + x: x - 100, + y: y - 100, + }), + }), + }; }); + +describe("Drag & drop node creation", () => { + + test("drops a phase node inside the canvas and adds it with transformed position", async () => { + const user = userEvent.setup(); + + const { container } = render(); + + // --- Mock ReactFlow bounding box --- + // Your DndToolbar checks these values: + const flowEl = container.querySelector('.react-flow'); + jest.spyOn(flowEl!, 'getBoundingClientRect').mockReturnValue({ + left: 0, + right: 800, + top: 0, + bottom: 600, + width: 800, + height: 600, + x: 0, + y: 0, + toJSON: () => {}, + }); + + + const phaseLabel = getByTestId(container, 'draggable-phase') + + await user.pointer([ + // touch the screen at element1 + {keys: '[TouchA>]', target: phaseLabel}, + // move the touch pointer to element2 + {pointerName: 'TouchA', coords: {x: 300, y: 250}}, + // release the touch pointer at the last position (element2) + {keys: '[/TouchA]'}, + ]); + + // Read the Zustand store + const { nodes } = useFlowStore.getState(); + + // --- Assertions --- + expect(nodes.length).toBe(1); + + const node = nodes[0]; + + expect(node.type).toBe("phase"); + expect(node.id).toBe("phase-1"); + + // screenToFlowPosition was mocked to subtract 100 + expect(node.position).toEqual({ + x: 200, + y: 150, + }); + }); +}); \ No newline at end of file diff --git a/test/pages/visProgPage/visualProgrammingUI/components/ScrollIntoView.test.tsx b/test/pages/visProgPage/visualProgrammingUI/components/ScrollIntoView.test.tsx new file mode 100644 index 0000000..2a91e85 --- /dev/null +++ b/test/pages/visProgPage/visualProgrammingUI/components/ScrollIntoView.test.tsx @@ -0,0 +1,14 @@ +import { render } from '@testing-library/react'; +import { act } from '@testing-library/react'; +import ScrollIntoView from '../../../../../src/components/ScrollIntoView'; + +test('scrolls the element into view on render', () => { + const scrollMock = jest.fn(); + HTMLElement.prototype.scrollIntoView = scrollMock; + + act(() => { + render(); + }); + + expect(scrollMock).toHaveBeenCalledWith({ behavior: 'smooth' }); +}); diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx new file mode 100644 index 0000000..25c9947 --- /dev/null +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx @@ -0,0 +1,744 @@ +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 NormNode, { NormReduce, NormConnects, type NormNodeData } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode' +import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores'; +import type { Node } from '@xyflow/react'; +import '@testing-library/jest-dom' + + + +describe('NormNode', () => { + let user: ReturnType; + + beforeEach(() => { + user = userEvent.setup(); + }); + + describe('Rendering', () => { + it('should render the norm node with default data', () => { + const mockNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Test Norm', + droppable: true, + norm: '', + hasReduce: true, + }, + }; + + renderWithProviders( + + ); + + expect(screen.getByPlaceholderText('Pepper should ...')).toBeInTheDocument(); + }); + + it('should render with pre-populated norm text', () => { + const mockNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Test Norm', + droppable: true, + norm: 'Be respectful to humans', + hasReduce: true, + }, + }; + + renderWithProviders( + + ); + + const input = screen.getByDisplayValue('Be respectful to humans'); + expect(input).toBeInTheDocument(); + }); + + it('should render with selected state', () => { + const mockNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Test Norm', + droppable: true, + norm: '', + hasReduce: true, + }, + }; + + renderWithProviders( + + ); + + const norm = screen.getByText("Norm :") + expect(norm).toBeInTheDocument(); + }); + + it('should render with dragging state', () => { + const mockNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Test Norm', + droppable: true, + norm: 'Dragged norm', + hasReduce: true, + }, + }; + + renderWithProviders( + + ); + + const input = screen.getByDisplayValue('Dragged norm'); + expect(input).toBeInTheDocument(); + }); + }); + + describe('User Interactions', () => { + it('should update norm text when user types in the input field', async () => { + const mockNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Test Norm', + droppable: true, + norm: '', + hasReduce: true, + }, + }; + + useFlowStore.setState({ + nodes: [mockNode], + edges: [], + }); + + renderWithProviders( + + ); + + const input = screen.getByPlaceholderText('Pepper should ...'); + await user.type(input, 'Be polite to guests{enter}'); + + await waitFor(() => { + const state = useFlowStore.getState(); + const updatedNode = state.nodes.find(n => n.id === 'norm-1'); + expect(updatedNode?.data.norm).toBe('Be polite to guests'); + }); + }); + + it('should handle clearing the norm text', async () => { + const mockNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Test Norm', + droppable: true, + norm: 'Initial norm text', + hasReduce: true, + }, + }; + + useFlowStore.setState({ + nodes: [mockNode], + edges: [], + }); + + renderWithProviders( + + ); + + const input = screen.getByDisplayValue('Initial norm text') as HTMLInputElement; + + // clearing the norm text is the same as just deleting all characters one by one + // TODO: FIGURE OUT A MORE EFFICIENT WAY, I"M SORRY, user.clear() just doesn't work:/ + for (let a = 0; a < 'Initial norm text'.length; a++){ + await user.type(input, '{backspace}') + } + await user.type(input,'{enter}') + + await waitFor(() => { + const state = useFlowStore.getState(); + const updatedNode = state.nodes.find(n => n.id === 'norm-1'); + expect(updatedNode?.data.norm).toBe(''); + }); + }); + + it('should update norm text multiple times', async () => { + const mockNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Test Norm', + droppable: true, + norm: '', + hasReduce: true, + }, + }; + + useFlowStore.setState({ + nodes: [mockNode], + edges: [], + }); + + renderWithProviders( + + ); + + const input = screen.getByPlaceholderText('Pepper should ...'); + await user.type(input, 'First norm{enter}'); + await waitFor(() => { + expect(useFlowStore.getState().nodes[0].data.norm).toBe('First norm'); + }); + + + // TODO: FIGURE OUT A MORE EFFICIENT WAY, I"M SORRY, user.clear() just doesn't work:/ + for (let a = 0; a < 'First norm'.length; a++){ + await user.type(input, '{backspace}') + } + + await user.type(input, 'Second norm{enter}'); + await waitFor(() => { + expect(useFlowStore.getState().nodes[0].data.norm).toBe('Second norm'); + }); + }); + + it('should handle special characters in norm text', async () => { + const mockNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Test Norm', + droppable: true, + norm: '', + hasReduce: true, + }, + }; + + useFlowStore.setState({ + nodes: [mockNode], + edges: [], + }); + + renderWithProviders( + + ); + + const input = screen.getByPlaceholderText('Pepper should ...'); + await user.type(input, "Don't harm & be nice!{enter}" ); + + await waitFor(() => { + expect(useFlowStore.getState().nodes[0].data.norm).toBe("Don't harm & be nice!"); + }); + }); + + it('should handle long norm text', async () => { + const longText = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.'; + const mockNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Test Norm', + droppable: true, + norm: '', + hasReduce: true, + }, + }; + + useFlowStore.setState({ + nodes: [mockNode], + edges: [], + }); + + renderWithProviders( + + ); + + const input = screen.getByPlaceholderText('Pepper should ...'); + await user.type(input, longText); + await user.type(input, "{enter}") + + await waitFor(() => { + expect(useFlowStore.getState().nodes[0].data.norm).toBe(longText); + }); + }); + }); + + describe('NormReduce Function', () => { + it('should reduce a norm node to its essential data', () => { + const normNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Safety Norm', + droppable: true, + norm: 'Never harm humans', + hasReduce: true, + }, + }; + + const allNodes: Node[] = [normNode]; + const result = NormReduce(normNode, allNodes); + + expect(result).toEqual({ + id: 'norm-1', + label: 'Safety Norm', + norm: 'Never harm humans', + }); + }); + + it('should reduce multiple norm nodes independently', () => { + const norm1: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Norm 1', + droppable: true, + norm: 'Be helpful', + hasReduce: true, + }, + }; + + const norm2: Node = { + id: 'norm-2', + type: 'norm', + position: { x: 100, y: 0 }, + data: { + label: 'Norm 2', + droppable: true, + norm: 'Be honest', + hasReduce: true, + }, + }; + + const allNodes: Node[] = [norm1, norm2]; + + const result1 = NormReduce(norm1, allNodes); + const result2 = NormReduce(norm2, allNodes); + + expect(result1.id).toBe('norm-1'); + expect(result1.norm).toBe('Be helpful'); + expect(result2.id).toBe('norm-2'); + expect(result2.norm).toBe('Be honest'); + }); + + it('should handle empty norm text', () => { + const normNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Empty Norm', + droppable: true, + norm: '', + hasReduce: true, + }, + }; + + const result = NormReduce(normNode, [normNode]); + + expect(result.norm).toBe(''); + expect(result.id).toBe('norm-1'); + }); + + it('should preserve node label in reduction', () => { + const normNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Custom Label', + droppable: false, + norm: 'Test norm', + hasReduce: false, + }, + }; + + const result = NormReduce(normNode, [normNode]); + + expect(result.label).toBe('Custom Label'); + }); + }); + + describe('NormConnects Function', () => { + it('should handle connection without errors', () => { + const normNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Test Norm', + droppable: true, + norm: 'Test', + hasReduce: true, + }, + }; + + const phaseNode: Node = { + id: 'phase-1', + type: 'phase', + position: { x: 100, y: 0 }, + data: { + label: 'Phase 1', + droppable: true, + children: [], + hasReduce: true, + }, + }; + + expect(() => { + NormConnects(normNode, phaseNode, true); + }).not.toThrow(); + }); + + it('should handle connection when norm is target', () => { + const normNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Test Norm', + droppable: true, + norm: 'Test', + hasReduce: true, + }, + }; + + const phaseNode: Node = { + id: 'phase-1', + type: 'phase', + position: { x: 100, y: 0 }, + data: { + label: 'Phase 1', + droppable: true, + children: [], + hasReduce: true, + }, + }; + + expect(() => { + NormConnects(normNode, phaseNode, false); + }).not.toThrow(); + }); + + it('should handle self-connection', () => { + const normNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Test Norm', + droppable: true, + norm: 'Test', + hasReduce: true, + }, + }; + + expect(() => { + NormConnects(normNode, normNode, true); + }).not.toThrow(); + }); + }); + + describe('Integration with Store', () => { + it('should properly update the store when editing norm text', async () => { + const mockNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Test Norm', + droppable: true, + norm: '', + hasReduce: true, + }, + }; + + useFlowStore.setState({ + nodes: [mockNode], + edges: [], + }); + + renderWithProviders( + + ); + + const input = screen.getByPlaceholderText('Pepper should ...'); + + // TODO: FIGURE OUT A MORE EFFICIENT WAY, I"M SORRY, user.clear() just doesn't work:/ + for (let a = 0; a < 20; a++){ + await user.type(input, '{backspace}') + } + await user.type(input, 'New norm value{enter}'); + + await waitFor(() => { + const state = useFlowStore.getState(); + expect(state.nodes).toHaveLength(1); + expect(state.nodes[0].id).toBe('norm-1'); + expect(state.nodes[0].data.norm).toBe('New norm value'); + }); + }); + + it('should not affect other nodes when updating one norm node', async () => { + const norm1: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Norm 1', + droppable: true, + norm: 'Original norm 1', + hasReduce: true, + }, + }; + + const norm2: Node = { + id: 'norm-2', + type: 'norm', + position: { x: 100, y: 0 }, + data: { + label: 'Norm 2', + droppable: true, + norm: 'Original norm 2', + hasReduce: true, + }, + }; + + useFlowStore.setState({ + nodes: [norm1, norm2], + edges: [], + }); + + renderWithProviders( + + ); + + const input = screen.getByDisplayValue('Original norm 1') as HTMLInputElement; + + + // TODO: FIGURE OUT A MORE EFFICIENT WAY, I"M SORRY, user.clear() just doesn't work:/ + for (let a = 0; a < 20; a++){ + await user.type(input, '{backspace}') + } + await user.type(input, 'Updated norm 1{enter}'); + + await waitFor(() => { + const state = useFlowStore.getState(); + const updatedNorm1 = state.nodes.find(n => n.id === 'norm-1'); + const unchangedNorm2 = state.nodes.find(n => n.id === 'norm-2'); + + expect(updatedNorm1?.data.norm).toBe('Updated norm 1'); + expect(unchangedNorm2?.data.norm).toBe('Original norm 2'); + }); + }); + + it('should maintain data consistency with multiple rapid updates', async () => { + const mockNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Test Norm', + droppable: true, + norm: 'haa haa fuyaaah - link', + hasReduce: true, + }, + }; + + useFlowStore.setState({ + nodes: [mockNode], + edges: [], + }); + + renderWithProviders( + + ); + + const input = screen.getByPlaceholderText('Pepper should ...'); + + await user.type(input, 'a'); + await waitFor(() => { + expect(useFlowStore.getState().nodes[0].data.norm).toBe('haa haa fuyaaah - link'); + }); + + await user.type(input, 'b'); + await waitFor(() => { + expect(useFlowStore.getState().nodes[0].data.norm).toBe('haa haa fuyaaah - link'); + }); + + await user.type(input, 'c'); + await waitFor(() => { + expect(useFlowStore.getState().nodes[0].data.norm).toBe('haa haa fuyaaah - link'); + }, { timeout: 3000 }); + }); + }); +}); \ No newline at end of file diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/StartNode.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/StartNode.test.tsx new file mode 100644 index 0000000..c5ec43a --- /dev/null +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/StartNode.test.tsx @@ -0,0 +1,98 @@ +import { describe, it } from '@jest/globals'; +import '@testing-library/jest-dom'; +import { screen } from '@testing-library/react'; +import type { Node } from '@xyflow/react'; +import { renderWithProviders } from '../.././/./../../test-utils/test-utils'; +import StartNode, { StartReduce, StartConnects } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode'; + + +describe('StartNode', () => { + + + describe('Rendering', () => { + it('renders the StartNode correctly', () => { + const mockNode: Node = { + id: 'start-1', + type: 'start', // TypeScript now knows this is a string + position: { x: 0, y: 0 }, + data: { + label: 'Start Node', + droppable: false, + hasReduce: true, + }, + }; + + renderWithProviders( + + ); + + + expect(screen.getByText('Start')).toBeInTheDocument(); + + // The handle should exist in the DOM + expect(document.querySelector('[data-handleid="source"]')).toBeInTheDocument(); + + }); + }); + + describe('StartReduce Function', () => { + it('reduces the StartNode to its minimal structure', () => { + const mockNode: Node = { + id: 'start-1', + type: 'start', + position: { x: 0, y: 0 }, + data: { + label: 'Start Node', + droppable: false, + hasReduce: true, + }, + }; + + const result = StartReduce(mockNode, [mockNode]); + expect(result).toEqual({ id: 'start-1' }); + }); + }); + + describe('StartConnects Function', () => { + it('handles connections without throwing', () => { + const startNode: Node = { + id: 'start-1', + type: 'start', + position: { x: 0, y: 0 }, + data: { + label: 'Start Node', + droppable: false, + hasReduce: true, + }, + }; + + const otherNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 100, y: 0 }, + data: { + label: 'Norm Node', + droppable: true, + norm: 'test', + hasReduce: true, + }, + }; + + expect(() => StartConnects(startNode, otherNode, true)).not.toThrow(); + expect(() => StartConnects(startNode, otherNode, false)).not.toThrow(); + }); + }); +}); diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/TriggerNode.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/TriggerNode.test.tsx new file mode 100644 index 0000000..55a46e3 --- /dev/null +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/TriggerNode.test.tsx @@ -0,0 +1,246 @@ +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 TriggerNode, { TriggerReduce, TriggerConnects, TriggerNodeCanConnect, type TriggerNodeData } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode'; +import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores'; +import type { Node } from '@xyflow/react'; +import '@testing-library/jest-dom'; + +describe('TriggerNode', () => { + let user: ReturnType; + + beforeEach(() => { + user = userEvent.setup(); + }); + + describe('Rendering', () => { + it('should render TriggerNode with keywords type', () => { + const mockNode: Node = { + id: 'trigger-1', + type: 'trigger', + position: { x: 0, y: 0 }, + data: { + label: 'Keyword Trigger', + droppable: true, + triggerType: 'keywords', + triggers: [], + hasReduce: true, + }, + }; + + renderWithProviders( + + ); + + expect(screen.getByText(/Triggers when the keyword is spoken/i)).toBeInTheDocument(); + expect(screen.getByPlaceholderText('...')).toBeInTheDocument(); + }); + + it('should render TriggerNode with emotion type', () => { + const mockNode: Node = { + id: 'trigger-2', + type: 'trigger', + position: { x: 0, y: 0 }, + data: { + label: 'Emotion Trigger', + droppable: true, + triggerType: 'emotion', + triggers: [], + hasReduce: true, + }, + }; + + renderWithProviders( + + ); + + expect(screen.getByText(/Emotion\?/i)).toBeInTheDocument(); + }); + }); + + describe('User Interactions', () => { + it('should add a new keyword', async () => { + const mockNode: Node = { + id: 'trigger-1', + type: 'trigger', + position: { x: 0, y: 0 }, + data: { + label: 'Keyword Trigger', + droppable: true, + triggerType: 'keywords', + triggers: [], + hasReduce: true, + }, + }; + + useFlowStore.setState({ nodes: [mockNode], edges: [] }); + + renderWithProviders( + + ); + + const input = screen.getByPlaceholderText('...'); + await user.type(input, 'hello{enter}'); + + await waitFor(() => { + const node = useFlowStore.getState().nodes.find(n => n.id === 'trigger-1') as Node | undefined; + expect(node?.data.triggers.length).toBe(1); + expect(node?.data.triggers[0].keyword).toBe('hello'); + }); + + }); + + it('should remove a keyword when cleared', async () => { + const mockNode: Node = { + id: 'trigger-1', + type: 'trigger', + position: { x: 0, y: 0 }, + data: { + label: 'Keyword Trigger', + droppable: true, + triggerType: 'keywords', + triggers: [{ id: 'kw1', keyword: 'hello' }], + hasReduce: true, + }, + }; + + useFlowStore.setState({ nodes: [mockNode], edges: [] }); + + renderWithProviders( + + ); + + const input = screen.getByDisplayValue('hello'); + for (let i = 0; i < 'hello'.length; i++) { + await user.type(input, '{backspace}'); + } + await user.type(input, '{enter}'); + + await waitFor(() => { + const node = useFlowStore.getState().nodes.find(n => n.id === 'trigger-1') as Node | undefined; + expect(node?.data.triggers.length).toBe(0); + }); + + }); + }); + + describe('TriggerReduce Function', () => { + it('should reduce a trigger node to its essential data', () => { + const triggerNode: Node = { + id: 'trigger-1', + type: 'trigger', + position: { x: 0, y: 0 }, + data: { + label: 'Keyword Trigger', + droppable: true, + triggerType: 'keywords', + triggers: [{ id: 'kw1', keyword: 'hello' }], + hasReduce: true, + }, + }; + + const allNodes: Node[] = [triggerNode]; + const result = TriggerReduce(triggerNode, allNodes); + + expect(result).toEqual({ + id: 'trigger-1', + type: 'keywords', + label: 'Keyword Trigger', + keywords: [{ id: 'kw1', keyword: 'hello' }], + }); + }); + }); + + + describe('TriggerConnects Function', () => { + it('should handle connection without errors', () => { + const node1: Node = { + id: 'trigger-1', + type: 'trigger', + position: { x: 0, y: 0 }, + data: { + label: 'Trigger 1', + droppable: true, + triggerType: 'keywords', + triggers: [], + hasReduce: true, + }, + }; + + const node2: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 100, y: 0 }, + data: { + label: 'Norm 1', + droppable: true, + norm: 'test', + hasReduce: true, + }, + }; + + expect(() => { + TriggerConnects(node1, node2, true); + TriggerConnects(node1, node2, false); + }).not.toThrow(); + }); + + it('should return true for TriggerNodeCanConnect if connection exists', () => { + const connection = { source: 'trigger-1', target: 'norm-1' }; + expect(TriggerNodeCanConnect(connection as any)).toBe(true); + }); + }); +}); diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx new file mode 100644 index 0000000..7fb0709 --- /dev/null +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx @@ -0,0 +1,151 @@ +import { describe, beforeEach } from '@jest/globals'; +import { screen } from '@testing-library/react'; +import { renderWithProviders } from '../.././/./../../test-utils/test-utils'; +import type { XYPosition } from '@xyflow/react'; +import { NodeTypes, NodeDefaults, NodeConnects, NodeReduces, NodesInPhase } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/NodeRegistry'; +import '@testing-library/jest-dom' +import { createElement } from 'react'; +import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores'; + + +describe('NormNode', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + function createNode(id: string, type: string, position: XYPosition, data: Record, deletable?: boolean) { + const defaultData = NodeDefaults[type as keyof typeof NodeDefaults] + const newData = { + id: id, + type: type, + position: position, + data: data, + deletable: deletable, + } + return {...defaultData, ...newData} + } + + + /** + * Reduces the graph into its phases' information and recursively calls their reducing function + */ + function graphReducer() { + const { nodes } = useFlowStore.getState(); + return nodes + .filter((n) => n.type == 'phase') + .map((n) => { + const reducer = NodeReduces['phase']; + return reducer(n, nodes) + }); + } + + function getAllTypes() { + return Object.entries(NodeTypes).map(([t])=>t) + } + + describe('Rendering', () => { + test.each(getAllTypes())('it should render %s node with the default data', (nodeType) => { + const lengthBefore = screen.getAllByText(/.*/).length; + + const newNode = createNode(nodeType + "1", nodeType, {x: 200, y:200}, {}); + + const found = Object.entries(NodeTypes).find(([t]) => t === nodeType); + const uiElement = found ? found[1] : null; + + expect(uiElement).not.toBeNull(); + const props = { + id: newNode.id, + type: newNode.type as string, + data: newNode.data as any, + selected: false, + isConnectable: true, + zIndex: 0, + dragging: false, + selectable: true, + deletable: true, + draggable: true, + positionAbsoluteX: 0, + positionAbsoluteY: 0, + }; + + renderWithProviders(createElement(uiElement as React.ComponentType, props)); + const lengthAfter = screen.getAllByText(/.*/).length; + + expect(lengthBefore + 1 === lengthAfter); + }); + + }); + + + describe('Connecting', () => { + test.each(getAllTypes())('it should call the connect function when %s node is connected', (nodeType) => { + // Create two nodes - one of the current type and one to connect to + const sourceNode = createNode('source-1', nodeType, {x: 100, y: 100}, {}); + const targetNode = createNode('target-1', 'end', {x: 300, y: 100}, {}); + + // Add nodes to store + useFlowStore.setState({ nodes: [sourceNode, targetNode] }); + + // Spy on the connect functions + const sourceConnectSpy = jest.spyOn(NodeConnects, nodeType as keyof typeof NodeConnects); + const targetConnectSpy = jest.spyOn(NodeConnects, 'end'); + + // Simulate connection + useFlowStore.getState().onConnect({ + source: 'source-1', + target: 'target-1', + sourceHandle: null, + targetHandle: null, + }); + + // Verify the connect functions were called + expect(sourceConnectSpy).toHaveBeenCalledWith(sourceNode, targetNode, true); + expect(targetConnectSpy).toHaveBeenCalledWith(targetNode, sourceNode, false); + + sourceConnectSpy.mockRestore(); + targetConnectSpy.mockRestore(); + }); + }); + + describe('Reducing', () => { + test.each(getAllTypes())('it should correctly call/ not call the reduce function when %s node is in a phase', (nodeType) => { + // Create a phase node and a node of the current type + const phaseNode = createNode('phase-1', 'phase', {x: 200, y: 100}, { label: 'Test Phase', children: [] }); + const testNode = createNode('node-1', nodeType, {x: 100, y: 100}, {}); + + // Add the test node as a child of the phase + (phaseNode.data as any).children.push(testNode.id); + + // Add nodes to store + useFlowStore.setState({ nodes: [phaseNode, testNode] }); + + // Spy on the reduce functions + const phaseReduceSpy = jest.spyOn(NodeReduces, 'phase'); + const nodeReduceSpy = jest.spyOn(NodeReduces, nodeType as keyof typeof NodeReduces); + + // Simulate reducing - using the graphReducer + const result = graphReducer(); + + // Verify the reduce functions were called + expect(phaseReduceSpy).toHaveBeenCalledWith(phaseNode, [phaseNode, testNode]); + // Check if this node type is in NodesInPhase and returns false + const nodesInPhaseFunc = NodesInPhase[nodeType as keyof typeof NodesInPhase]; + if (nodesInPhaseFunc && nodesInPhaseFunc() === false && nodeType !== 'phase') { + // Node is NOT in phase, so it should NOT be called + expect(nodeReduceSpy).not.toHaveBeenCalled(); + } else { + // Node IS in phase, so it SHOULD be called + expect(nodeReduceSpy).toHaveBeenCalled(); + } + + // Verify the correct structure is present using NodesInPhase + expect(result).toHaveLength(nodeType !== 'phase' ? 1 : 2); + expect(result[0]).toHaveProperty('id', 'phase-1'); + expect(result[0]).toHaveProperty('label', 'Test Phase'); + + // Restore mocks + phaseReduceSpy.mockRestore(); + nodeReduceSpy.mockRestore(); + }); + }); +}); \ No newline at end of file diff --git a/test/test-utils/mocks.ts b/test/test-utils/mocks.ts new file mode 100644 index 0000000..21971c1 --- /dev/null +++ b/test/test-utils/mocks.ts @@ -0,0 +1,41 @@ +import { jest } from '@jest/globals'; +import React from 'react'; +import '@testing-library/jest-dom'; + +/** + * Mock for @xyflow/react + * Provides simplified versions of React Flow hooks and components + */ +jest.mock('@xyflow/react', () => ({ + useReactFlow: jest.fn(() => ({ + screenToFlowPosition: jest.fn((pos: any) => pos), + getNode: jest.fn(), + getNodes: jest.fn(() => []), + getEdges: jest.fn(() => []), + setNodes: jest.fn(), + setEdges: jest.fn(), + })), + ReactFlowProvider: ({ children }: { children: React.ReactNode }) => + React.createElement('div', { 'data-testid': 'react-flow-provider' }, children), + ReactFlow: ({ children, ...props }: any) => + React.createElement('div', { 'data-testid': 'react-flow', ...props }, children), + Handle: ({ type, position, id }: any) => + React.createElement('div', { 'data-testid': `handle-${type}-${id}`, 'data-position': position }), + Panel: ({ children, position }: any) => + React.createElement('div', { 'data-testid': 'panel', 'data-position': position }, children), + Controls: () => React.createElement('div', { 'data-testid': 'controls' }), + Background: () => React.createElement('div', { 'data-testid': 'background' }), +})); + +/** + * Mock for @neodrag/react + * Simplifies drag behavior for testing + */ +jest.mock('@neodrag/react', () => ({ + useDraggable: jest.fn((ref: any, options?: any) => { + // Store the options so we can trigger them in tests + if (ref && ref.current) { + (ref.current as any)._dragOptions = options; + } + }), +})); \ No newline at end of file diff --git a/test/test-utils/test-utils.tsx b/test/test-utils/test-utils.tsx new file mode 100644 index 0000000..2379d9c --- /dev/null +++ b/test/test-utils/test-utils.tsx @@ -0,0 +1,24 @@ +// __tests__/utils/test-utils.tsx +import { render, type RenderOptions } from '@testing-library/react'; +import { type ReactElement, type ReactNode } from 'react'; +import { ReactFlowProvider } from '@xyflow/react'; + +/** + * Custom render function that wraps components with necessary providers + * This ensures all components have access to ReactFlow context + */ +export function renderWithProviders( + ui: ReactElement, + options?: Omit +) { + function Wrapper({ children }: { children: ReactNode }) { + return {children}; + } + + return render(ui, { wrapper: Wrapper, ...options }); +} + + +// Re-export everything from testing library +//eslint-disable-next-line react-refresh/only-export-components +export * from '@testing-library/react'; -- 2.49.1 From 062e9e3f38333edc42e0b064804891fe2f1ec169 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Wed, 10 Dec 2025 15:38:54 +0100 Subject: [PATCH 134/184] feat: add critical checkbox to the norm node, send it with the program, add test. ref: N25B-390 --- .../visualProgrammingUI/VisProgStores.tsx | 2 +- .../nodes/NormNode.default.ts | 1 + .../visualProgrammingUI/nodes/NormNode.tsx | 16 ++++++ .../nodes/NormNode.test.tsx | 49 +++++++++++++++++++ 4 files changed, 67 insertions(+), 1 deletion(-) diff --git a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx index 5bcd855..5154a8e 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx @@ -40,7 +40,7 @@ const initialNodes : Node[] = [ createNode('start', 'start', {x: 100, y: 100}, {label: "Start"}, false), createNode('end', 'end', {x: 500, y: 100}, {label: "End"}, false), createNode('phase-1', 'phase', {x:200, y:100}, {label: "Phase 1", children : []}), - createNode('norms-1', 'norm', {x:-200, y:100}, {label: "Initial Norms", normList: ["Be a robot", "get good"]}), + createNode('norms-1', 'norm', {x:-200, y:100}, {label: "Initial Norms", normList: ["Be a robot", "get good"], critical:false}), ]; // * Initial edges * / diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.default.ts b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.default.ts index 12cb182..4b4a3ed 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.default.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.default.ts @@ -8,4 +8,5 @@ export const NormNodeDefaults: NormNodeData = { droppable: true, norm: "", hasReduce: true, + critical: false, }; \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx index 31d92a5..59e9bc1 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx @@ -21,6 +21,7 @@ export type NormNodeData = { droppable: boolean; norm: string; hasReduce: boolean; + critical: boolean; }; export type NormNode = Node @@ -35,11 +36,16 @@ export default function NormNode(props: NodeProps) { const {updateNodeData} = useFlowStore(); const text_input_id = `norm_${props.id}_text_input`; + const checkbox_id = `goal_${props.id}_checkbox`; const setValue = (value: string) => { updateNodeData(props.id, {norm: value}); } + const setAchieved = (value: boolean) => { + updateNodeData(props.id, {...data, critical: value}); + } + return <>
    @@ -52,6 +58,15 @@ export default function NormNode(props: NodeProps) { placeholder={"Pepper should ..."} />
    +
    + + setAchieved(e.target.checked)} + /> +
    ; @@ -69,6 +84,7 @@ export function NormReduce(node: Node, _nodes: Node[]) { id: node.id, label: data.label, norm: data.norm, + critical: data.critical, } } diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx index 25c9947..8cba780 100644 --- a/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx @@ -94,6 +94,7 @@ describe('NormNode', () => { droppable: true, norm: '', hasReduce: true, + critical: false }, }; @@ -622,6 +623,54 @@ describe('NormNode', () => { }); }); + it('should properly update the store when editing critical checkbox', async () => { + const mockNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Test Norm', + droppable: true, + norm: '', + hasReduce: true, + critical: false, + }, + }; + + useFlowStore.setState({ + nodes: [mockNode], + edges: [], + }); + + renderWithProviders( + + ); + + const checkbox = screen.getByLabelText('Critical:'); + await user.click(checkbox); + + await waitFor(() => { + const state = useFlowStore.getState(); + expect(state.nodes).toHaveLength(1); + expect(state.nodes[0].id).toBe('norm-1'); + expect(state.nodes[0].data.norm).toBe(''); + expect(state.nodes[0].data.critical).toBe(true); + }); + }); + it('should not affect other nodes when updating one norm node', async () => { const norm1: Node = { id: 'norm-1', -- 2.49.1 From d5480f957b21f228ef5384c7fce85edf4d0ca886 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Thu, 11 Dec 2025 09:54:34 +0000 Subject: [PATCH 135/184] Apply 1 suggestion(s) to 1 file(s) Co-authored-by: Twirre --- src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx index 59e9bc1..14a5ca9 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx @@ -42,7 +42,7 @@ export default function NormNode(props: NodeProps) { updateNodeData(props.id, {norm: value}); } - const setAchieved = (value: boolean) => { + const setCritical = (value: boolean) => { updateNodeData(props.id, {...data, critical: value}); } -- 2.49.1 From 62c8118650f6a6f3d044367b2f164d932e7d3223 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Thu, 11 Dec 2025 09:54:39 +0000 Subject: [PATCH 136/184] Apply 1 suggestion(s) to 1 file(s) Co-authored-by: Twirre --- src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx index 14a5ca9..29b5865 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx @@ -64,7 +64,7 @@ export default function NormNode(props: NodeProps) { id={checkbox_id} type={"checkbox"} checked={data.critical || false} - onChange={(e) => setAchieved(e.target.checked)} + onChange={(e) => setCritical(e.target.checked)} />
    -- 2.49.1 From 10d5a15c886b09e849f4b9c85289beb76cbb0fab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Thu, 11 Dec 2025 14:12:26 +0100 Subject: [PATCH 137/184] feat: basic belief node with the basic belief types defined in KB. ref: N25B-408 --- src/App.css | 8 + src/pages/VisProgPage/VisProg.module.css | 13 ++ .../visualProgrammingUI/NodeRegistry.ts | 9 +- .../nodes/BasicBeliefNode.default.ts | 12 ++ .../nodes/BasicBeliefNode.tsx | 174 ++++++++++++++++++ 5 files changed, 214 insertions(+), 2 deletions(-) create mode 100644 src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.default.ts create mode 100644 src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx diff --git a/src/App.css b/src/App.css index a241d03..8e078f6 100644 --- a/src/App.css +++ b/src/App.css @@ -248,3 +248,11 @@ button.no-button { text-decoration: underline; } } + +.flex-center-x { + display: flex; + justify-content: center; /* horizontal centering */ + text-align: center; /* center multi-line text */ + width: 100%; /* allow it to stretch */ + flex-wrap: wrap; /* optional: let text wrap naturally */ +} \ No newline at end of file diff --git a/src/pages/VisProgPage/VisProg.module.css b/src/pages/VisProgPage/VisProg.module.css index 5f2aa78..14619c5 100644 --- a/src/pages/VisProgPage/VisProg.module.css +++ b/src/pages/VisProgPage/VisProg.module.css @@ -71,6 +71,11 @@ filter: drop-shadow(0 0 0.25rem red); } +.node-basic_belief { + outline: plum solid 2pt; + filter: drop-shadow(0 0 0.25rem plum); +} + .draggable-node { padding: 3px 10px; background-color: canvas; @@ -126,3 +131,11 @@ outline: red solid 2pt; filter: drop-shadow(0 0 0.25rem red); } + +.draggable-node-basic_belief { + padding: 3px 10px; + background-color: canvas; + border-radius: 5pt; + outline: plum solid 2pt; + filter: drop-shadow(0 0 0.25rem plum); +} \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts b/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts index 8812434..04dabf1 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts @@ -10,6 +10,8 @@ import GoalNode, { GoalConnects, GoalReduce } from "./nodes/GoalNode"; import { GoalNodeDefaults } from "./nodes/GoalNode.default"; import TriggerNode, { TriggerConnects, TriggerReduce } from "./nodes/TriggerNode"; import { TriggerNodeDefaults } from "./nodes/TriggerNode.default"; +import BasicBeliefNode, { BasicBeliefConnects, BasicBeliefReduce } from "./nodes/BasicBeliefNode"; +import { BasicBeliefNodeDefaults } from "./nodes/BasicBeliefNode.default"; /** * Registered node types in the visual programming system. @@ -24,6 +26,7 @@ export const NodeTypes = { norm: NormNode, goal: GoalNode, trigger: TriggerNode, + basic_belief: BasicBeliefNode, }; /** @@ -38,6 +41,7 @@ export const NodeDefaults = { norm: NormNodeDefaults, goal: GoalNodeDefaults, trigger: TriggerNodeDefaults, + basic_belief: BasicBeliefNodeDefaults, }; @@ -54,6 +58,7 @@ export const NodeReduces = { norm: NormReduce, goal: GoalReduce, trigger: TriggerReduce, + basic_belief: BasicBeliefReduce, } @@ -69,6 +74,7 @@ export const NodeConnects = { norm: NormConnects, goal: GoalConnects, trigger: TriggerConnects, + basic_belief: BasicBeliefConnects, } /** @@ -79,7 +85,6 @@ export const NodeConnects = { export const NodeDeletes = { start: () => false, end: () => false, - test: () => false, // Used for coverage of universal/ undefined nodes } /** @@ -92,5 +97,5 @@ export const NodesInPhase = { start: () => false, end: () => false, phase: () => false, - test: () => false, // Used for coverage of universal/ undefined nodes + // basic_belief: () => false, } \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.default.ts b/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.default.ts new file mode 100644 index 0000000..72066c4 --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.default.ts @@ -0,0 +1,12 @@ +import type { BasicBeliefNodeData } from "./BasicBeliefNode"; + + +/** + * Default data for this node + */ +export const BasicBeliefNodeDefaults: BasicBeliefNodeData = { + label: "Belief", + droppable: true, + belief: {type: "keyword", id: "help", value: "help", label: "Keyword said:"}, + hasReduce: true, +}; \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx new file mode 100644 index 0000000..317a1eb --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx @@ -0,0 +1,174 @@ +import { + Handle, + type NodeProps, + Position, + type Connection, + type Edge, + type Node, +} from '@xyflow/react'; +import { Toolbar } from '../components/NodeComponents'; +import styles from '../../VisProg.module.css'; +import useFlowStore from '../VisProgStores'; +import { TextField } from '../../../../components/TextField'; + +/** + * The default data structure for a BasicBelief node + * + * Represents configuration for a node that activates when a specific condition is met, + * such as keywords being spoken or emotions detected. + * + * @property label: the display label of this BasicBelief node. + * @property droppable: Whether this node can be dropped from the toolbar (default: true). + * @property BasicBeliefType - The type of BasicBelief ("keywords" or a custom string). + * @property BasicBeliefs - The list of keyword BasicBeliefs (if applicable). + * @property hasReduce - Whether this node supports reduction logic. + */ +export type BasicBeliefNodeData = { + label: string; + droppable: boolean; + belief: BasicBeliefType; + hasReduce: boolean; +}; + +// These are all the types a basic belief could be. +type BasicBeliefType = Keyword | Semantic | Object | Emotion +type Keyword = { type: "keyword", id: string, value: string, label: "Keyword said:"}; +type Semantic = { type: "semantic", id: string, value: string, label: "Detected with LLM:"}; +type Object = { type: "object", id: string, value: string, label: "Object found:"}; +type Emotion = { type: "emotion", id: string, value: string, label: "Emotion recognised:"}; + +export type BasicBeliefNode = Node + + +/** + * Determines whether a BasicBelief node can connect to another node or edge. + * + * @param connection - The connection or edge being attempted to connect towards. + * @returns `true` if the connection is defined; otherwise, `false`. + */ +export function BasicBeliefNodeCanConnect(connection: Connection | Edge): boolean { + return (connection != undefined); +} + +/** + * Defines how a BasicBelief node should be rendered + * @param props - Node properties provided by React Flow, including `id` and `data`. + * @returns The rendered BasicBeliefNode React element (React.JSX.Element). + */ +export default function BasicBeliefNode(props: NodeProps) { + const data = props.data; + const {updateNodeData} = useFlowStore(); + const updateValue = (value: string) => updateNodeData(props.id, {...data, belief: {...data.belief, value: value}}); + const label_input_id = `basic_belief_${props.id}_label_input`; + + type BeliefString = BasicBeliefType["type"]; + + function updateBeliefType(newType: BeliefString) { + updateNodeData(props.id, { + ...data, + belief: { + ...data.belief, + type: newType, + }, + }); + } + + + // Use this + const emotionOptions = ["Happy", "Angry", "Sad", "Cheerful"] + + + let placeholder = "" + let wrapping = "" + switch (props.data.belief.type) { + case ("keyword"): + placeholder = "keyword..." + wrapping = '"' + break; + case ("semantic"): + placeholder = "word..." + wrapping = '"' + break; + case ("object"): + placeholder = "object..." + break; + case ("emotion"): + // TODO: emotion should probably be a drop-down menu rather than a string + // So this placeholder won't hold for always + placeholder = "emotion..." + break; + default: + break; + } + + return ( + <> + +
    +
    + +
    +
    + + {wrapping} + + {data.belief.type === "emotion" && ( + + )} + + + {data.belief.type !== "emotion" && + ()} + {wrapping} +
    + +
    + + ); +}; + +/** + * Reduces each BasicBelief, including its children down into its core data. + * @param node - The BasicBelief node to reduce. + * @param _nodes - The list of all nodes in the current flow graph. + * @returns A simplified object containing the node label and its list of BasicBeliefs. + */ +export function BasicBeliefReduce(node: BasicBeliefNode, _nodes: Node[]) { + const data = node.data; + return { + id: node.id, + type: data.belief.type, + value: data.belief.value + } +} + +/** + * This function is called whenever a connection is made with this node type (BasicBelief) + * @param _thisNode the node of this node type which function is called + * @param _otherNode the other node which was part of the connection + * @param _isThisSource whether this instance of the node was the source in the connection, true = yes. + */ +export function BasicBeliefConnects(_thisNode: Node, _otherNode: Node, _isThisSource: boolean) { +} \ No newline at end of file -- 2.49.1 From 58ab95eee14469189076b346b84d1dc3ede9e6b6 Mon Sep 17 00:00:00 2001 From: "Gerla, J. (Justin)" Date: Sun, 14 Dec 2025 21:56:18 +0000 Subject: [PATCH 138/184] fix: edge-disconnections-are-not-reflected-in-reduced-program --- src/pages/VisProgPage/VisProg.tsx | 3 + .../visualProgrammingUI/NodeRegistry.ts | 100 ++++++- .../visualProgrammingUI/VisProgStores.tsx | 78 +++-- .../visualProgrammingUI/VisProgTypes.tsx | 6 +- .../visualProgrammingUI/nodes/EndNode.tsx | 40 ++- .../visualProgrammingUI/nodes/GoalNode.tsx | 40 ++- .../visualProgrammingUI/nodes/NormNode.tsx | 39 ++- .../visualProgrammingUI/nodes/PhaseNode.tsx | 51 +++- .../visualProgrammingUI/nodes/StartNode.tsx | 35 ++- .../visualProgrammingUI/nodes/TriggerNode.tsx | 36 ++- .../VisProgStores.test.tsx | 266 +++++++++++++++++- .../nodes/NormNode.test.tsx | 15 +- .../nodes/StartNode.test.tsx | 11 +- .../nodes/TriggerNode.test.tsx | 13 +- .../nodes/UniversalNodes.test.tsx | 14 +- 15 files changed, 639 insertions(+), 108 deletions(-) diff --git a/src/pages/VisProgPage/VisProg.tsx b/src/pages/VisProgPage/VisProg.tsx index 0933d28..06e072c 100644 --- a/src/pages/VisProgPage/VisProg.tsx +++ b/src/pages/VisProgPage/VisProg.tsx @@ -39,6 +39,7 @@ const selector = (state: FlowState) => ({ nodes: state.nodes, edges: state.edges, onNodesChange: state.onNodesChange, + onEdgesDelete: state.onEdgesDelete, onEdgesChange: state.onEdgesChange, onConnect: state.onConnect, onReconnectStart: state.onReconnectStart, @@ -62,6 +63,7 @@ const VisProgUI = () => { const { nodes, edges, onNodesChange, + onEdgesDelete, onEdgesChange, onConnect, onReconnect, @@ -91,6 +93,7 @@ const VisProgUI = () => { defaultEdgeOptions={DEFAULT_EDGE_OPTIONS} nodeTypes={NodeTypes} onNodesChange={onNodesChange} + onEdgesDelete={onEdgesDelete} onEdgesChange={onEdgesChange} onReconnect={onReconnect} onReconnectStart={onReconnectStart} diff --git a/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts b/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts index 8812434..77a835d 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts @@ -1,14 +1,50 @@ -import StartNode, { StartConnects, StartReduce } from "./nodes/StartNode"; -import EndNode, { EndConnects, EndReduce } from "./nodes/EndNode"; -import PhaseNode, { PhaseConnects, PhaseReduce } from "./nodes/PhaseNode"; -import NormNode, { NormConnects, NormReduce } from "./nodes/NormNode"; +import EndNode, { + EndConnectionTarget, + EndConnectionSource, + EndDisconnectionTarget, + EndDisconnectionSource, + EndReduce +} from "./nodes/EndNode"; import { EndNodeDefaults } from "./nodes/EndNode.default"; +import StartNode, { + StartConnectionTarget, + StartConnectionSource, + StartDisconnectionTarget, + StartDisconnectionSource, + StartReduce +} from "./nodes/StartNode"; import { StartNodeDefaults } from "./nodes/StartNode.default"; +import PhaseNode, { + PhaseConnectionTarget, + PhaseConnectionSource, + PhaseDisconnectionTarget, + PhaseDisconnectionSource, + PhaseReduce +} from "./nodes/PhaseNode"; import { PhaseNodeDefaults } from "./nodes/PhaseNode.default"; +import NormNode, { + NormConnectionTarget, + NormConnectionSource, + NormDisconnectionTarget, + NormDisconnectionSource, + NormReduce +} from "./nodes/NormNode"; import { NormNodeDefaults } from "./nodes/NormNode.default"; -import GoalNode, { GoalConnects, GoalReduce } from "./nodes/GoalNode"; +import GoalNode, { + GoalConnectionTarget, + GoalConnectionSource, + GoalDisconnectionTarget, + GoalDisconnectionSource, + GoalReduce +} from "./nodes/GoalNode"; import { GoalNodeDefaults } from "./nodes/GoalNode.default"; -import TriggerNode, { TriggerConnects, TriggerReduce } from "./nodes/TriggerNode"; +import TriggerNode, { + TriggerConnectionTarget, + TriggerConnectionSource, + TriggerDisconnectionTarget, + TriggerDisconnectionSource, + TriggerReduce +} from "./nodes/TriggerNode"; import { TriggerNodeDefaults } from "./nodes/TriggerNode.default"; /** @@ -60,15 +96,51 @@ export const NodeReduces = { /** * Connection functions for each node type. * - * These functions define how nodes of a particular type can connect to other nodes. + * These functions define any additional actions a node may perform + * when a new connection is made */ -export const NodeConnects = { - start: StartConnects, - end: EndConnects, - phase: PhaseConnects, - norm: NormConnects, - goal: GoalConnects, - trigger: TriggerConnects, +export const NodeConnections = { + Targets: { + start: StartConnectionTarget, + end: EndConnectionTarget, + phase: PhaseConnectionTarget, + norm: NormConnectionTarget, + goal: GoalConnectionTarget, + trigger: TriggerConnectionTarget, + }, + Sources: { + start: StartConnectionSource, + end: EndConnectionSource, + phase: PhaseConnectionSource, + norm: NormConnectionSource, + goal: GoalConnectionSource, + trigger: TriggerConnectionSource, + } +} + +/** + * Disconnection functions for each node type. + * + * These functions define any additional actions a node may perform + * when a connection is disconnected + */ +export const NodeDisconnections = { + Targets: { + start: StartDisconnectionTarget, + end: EndDisconnectionTarget, + phase: PhaseDisconnectionTarget, + norm: NormDisconnectionTarget, + goal: GoalDisconnectionTarget, + trigger: TriggerDisconnectionTarget, + }, + Sources: { + start: StartDisconnectionSource, + end: EndDisconnectionSource, + phase: PhaseDisconnectionSource, + norm: NormDisconnectionSource, + goal: GoalDisconnectionSource, + trigger: TriggerDisconnectionSource, + }, } /** diff --git a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx index 5bcd855..4bf91fe 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx @@ -9,7 +9,12 @@ import { type XYPosition, } from '@xyflow/react'; import type { FlowState } from './VisProgTypes'; -import { NodeDefaults, NodeConnects, NodeDeletes } from './NodeRegistry'; +import { + NodeDefaults, + NodeConnections as NodeCs, + NodeDisconnections as NodeDs, + NodeDeletes +} from './NodeRegistry'; import { UndoRedo } from "./EditorUndoRedo.ts"; @@ -71,10 +76,25 @@ const useFlowStore = create(UndoRedo((set, get) => ({ */ onNodesChange: (changes) => set({nodes: applyNodeChanges(changes, get().nodes)}), + onEdgesDelete: (edges) => { + + // we make sure any affected nodes get updated to reflect removal of edges + edges.forEach((edge) => { + const nodes = get().nodes; + + const sourceNode = nodes.find((n) => n.id == edge.source); + const targetNode = nodes.find((n) => n.id == edge.target); + + if (sourceNode) { NodeDs.Sources[sourceNode.type as keyof typeof NodeDs.Sources](sourceNode, edge.target); } + if (targetNode) { NodeDs.Targets[targetNode.type as keyof typeof NodeDs.Targets](targetNode, edge.source); } + }); + }, /** * Handles changes to edges triggered by ReactFlow. */ - onEdgesChange: (changes) => set({ edges: applyEdgeChanges(changes, get().edges) }), + onEdgesChange: (changes) => { + set({ edges: applyEdgeChanges(changes, get().edges) }) + }, /** * Handles creating a new connection between nodes. @@ -82,32 +102,16 @@ const useFlowStore = create(UndoRedo((set, get) => ({ */ onConnect: (connection) => { get().pushSnapshot(); + set({edges: addEdge(connection, get().edges)}); - const edges = addEdge(connection, get().edges); + // We make sure to perform any required data updates on the newly connected nodes const nodes = get().nodes; - // connection has: { source, sourceHandle, target, targetHandle } - // Let's find the source and target ID's. + const sourceNode = nodes.find((n) => n.id == connection.source); const targetNode = nodes.find((n) => n.id == connection.target); - // In case the nodes weren't found, return basic functionality. - if ( sourceNode == undefined - || targetNode == undefined - || sourceNode.type == undefined - || targetNode.type == undefined - ){ - set({ nodes, edges }); - return; - } - - // We should find out how their data changes by calling their respective functions. - const sourceConnectFunction = NodeConnects[sourceNode.type as keyof typeof NodeConnects] - const targetConnectFunction = NodeConnects[targetNode.type as keyof typeof NodeConnects] - - // We're going to have to update their data based on how they want to update it. - sourceConnectFunction(sourceNode, targetNode, true) - targetConnectFunction(targetNode, sourceNode, false) - set({ nodes, edges }); + if (sourceNode) { NodeCs.Sources[sourceNode.type as keyof typeof NodeCs.Sources](sourceNode, connection.target); } + if (targetNode) { NodeCs.Targets[targetNode.type as keyof typeof NodeCs.Targets](targetNode, connection.source); } }, /** @@ -116,6 +120,22 @@ const useFlowStore = create(UndoRedo((set, get) => ({ onReconnect: (oldEdge, newConnection) => { get().edgeReconnectSuccessful = true; set({ edges: reconnectEdge(oldEdge, newConnection, get().edges) }); + + // We make sure to perform any required data updates on the newly reconnected nodes + const nodes = get().nodes; + + const oldSourceNode = nodes.find((n) => n.id == oldEdge.source)!; + const oldTargetNode = nodes.find((n) => n.id == oldEdge.target)!; + const newSourceNode = nodes.find((n) => n.id == newConnection.source)!; + const newTargetNode = nodes.find((n) => n.id == newConnection.target)!; + + if (oldSourceNode === newSourceNode && oldTargetNode === newTargetNode) return; + + NodeCs.Sources[newSourceNode.type as keyof typeof NodeCs.Sources](newSourceNode, newConnection.target); + NodeCs.Targets[newTargetNode.type as keyof typeof NodeCs.Targets](newTargetNode, newConnection.source); + + NodeDs.Sources[oldSourceNode.type as keyof typeof NodeDs.Sources](oldSourceNode, oldEdge.target); + NodeDs.Targets[oldTargetNode.type as keyof typeof NodeDs.Targets](oldTargetNode, oldEdge.source); }, onReconnectStart: () => { @@ -128,11 +148,21 @@ const useFlowStore = create(UndoRedo((set, get) => ({ * if it is not reconnected to a node after detaching it * * @param _evt - the event - * @param {{id: string}} edge - the described edge + * @param edge - the described edge */ onReconnectEnd: (_evt, edge) => { if (!get().edgeReconnectSuccessful) { + // delete the edge from the flowState set({ edges: get().edges.filter((e) => e.id !== edge.id) }); + + // update node data to reflect the dropped edge + const nodes = get().nodes; + + const sourceNode = nodes.find((n) => n.id == edge.source)!; + const targetNode = nodes.find((n) => n.id == edge.target)!; + + NodeDs.Sources[sourceNode.type as keyof typeof NodeDs.Sources](sourceNode, edge.target); + NodeDs.Targets[targetNode.type as keyof typeof NodeDs.Targets](targetNode, edge.source); } set({ edgeReconnectSuccessful: true }); }, diff --git a/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx b/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx index b35bbf2..d5d8c06 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx @@ -1,5 +1,5 @@ // VisProgTypes.ts -import type { Edge, OnNodesChange, OnEdgesChange, OnConnect, OnReconnect, Node } from '@xyflow/react'; +import type {Edge, OnNodesChange, OnEdgesChange, OnConnect, OnReconnect, Node, OnEdgesDelete} from '@xyflow/react'; import type { NodeTypes } from './NodeRegistry'; import type {FlowSnapshot} from "./EditorUndoRedo.ts"; @@ -27,6 +27,8 @@ export type FlowState = { /** Handler for changes to nodes triggered by ReactFlow */ onNodesChange: OnNodesChange; + onEdgesDelete: OnEdgesDelete; + /** Handler for changes to edges triggered by ReactFlow */ onEdgesChange: OnEdgesChange; @@ -44,7 +46,7 @@ export type FlowState = { * @param _ - event or unused parameter * @param edge - the edge that finished reconnecting */ - onReconnectEnd: (_: unknown, edge: { id: string }) => void; + onReconnectEnd: (_: unknown, edge: Edge) => void; /** * Deletes a node and any connected edges. diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx index 9a496f2..57db571 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx @@ -5,7 +5,8 @@ import { type Node, } from '@xyflow/react'; import { Toolbar } from '../components/NodeComponents'; -import styles from '../../VisProg.module.css'; +import styles from '../../VisProg.module.css'; + /** * The typing of this node's data @@ -51,10 +52,37 @@ export function EndReduce(node: Node, _nodes: Node[]) { } /** - * Any connection functionality that should get called when a connection is made to this node type (end) - * @param _thisNode the node of which the functionality gets called - * @param _otherNode the other node which has connected - * @param _isThisSource whether this node is the one that is the source of the connection + * This function is called whenever a connection is made with this node type as the target + * @param _thisNode the node of this node type which function is called + * @param _sourceNodeId the source of the received connection */ -export function EndConnects(_thisNode: Node, _otherNode: Node, _isThisSource: boolean) { +export function EndConnectionTarget(_thisNode: Node, _sourceNodeId: string) { + // no additional connection logic exists yet +} + +/** + * This function is called whenever a connection is made with this node type as the source + * @param _thisNode the node of this node type which function is called + * @param _targetNodeId the target of the created connection + */ +export function EndConnectionSource(_thisNode: Node, _targetNodeId: string) { + // no additional connection logic exists yet +} + +/** + * This function is called whenever a connection is disconnected with this node type as the target + * @param _thisNode the node of this node type which function is called + * @param _sourceNodeId the source of the disconnected connection + */ +export function EndDisconnectionTarget(_thisNode: Node, _sourceNodeId: string) { + // no additional connection logic exists yet +} + +/** + * This function is called whenever a connection is disconnected with this node type as the source + * @param _thisNode the node of this node type which function is called + * @param _targetNodeId the target of the diconnected connection + */ +export function EndDisconnectionSource(_thisNode: Node, _targetNodeId: string) { + // no additional connection logic exists yet } \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx index bbacdf0..1564969 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx @@ -75,8 +75,8 @@ export default function GoalNode({id, data}: NodeProps) { /** * Reduces each Goal, including its children down into its relevant data. - * @param node: The Node Properties of this node. - * @param _nodes: all the nodes in the graph + * @param node The Node Properties of this node. + * @param _nodes all the nodes in the graph */ export function GoalReduce(node: Node, _nodes: Node[]) { const data = node.data as GoalNodeData; @@ -89,11 +89,37 @@ export function GoalReduce(node: Node, _nodes: Node[]) { } /** - * This function is called whenever a connection is made with this node type (Goal) + * This function is called whenever a connection is made with this node type as the target * @param _thisNode the node of this node type which function is called - * @param _otherNode the other node which was part of the connection - * @param _isThisSource whether this instance of the node was the source in the connection, true = yes. + * @param _sourceNodeId the source of the received connection */ -export function GoalConnects(_thisNode: Node, _otherNode: Node, _isThisSource: boolean) { - // Replace this for connection logic +export function GoalConnectionTarget(_thisNode: Node, _sourceNodeId: string) { + // no additional connection logic exists yet +} + +/** + * This function is called whenever a connection is made with this node type as the source + * @param _thisNode the node of this node type which function is called + * @param _targetNodeId the target of the created connection + */ +export function GoalConnectionSource(_thisNode: Node, _targetNodeId: string) { + // no additional connection logic exists yet +} + +/** + * This function is called whenever a connection is disconnected with this node type as the target + * @param _thisNode the node of this node type which function is called + * @param _sourceNodeId the source of the disconnected connection + */ +export function GoalDisconnectionTarget(_thisNode: Node, _sourceNodeId: string) { + // no additional connection logic exists yet +} + +/** + * This function is called whenever a connection is disconnected with this node type as the source + * @param _thisNode the node of this node type which function is called + * @param _targetNodeId the target of the diconnected connection + */ +export function GoalDisconnectionSource(_thisNode: Node, _targetNodeId: string) { + // no additional connection logic exists yet } \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx index 31d92a5..3b83fab 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx @@ -60,8 +60,8 @@ export default function NormNode(props: NodeProps) { /** * Reduces each Norm, including its children down into its relevant data. - * @param node: The Node Properties of this node. - * @param _nodes: all the nodes in the graph + * @param node The Node Properties of this node. + * @param _nodes all the nodes in the graph */ export function NormReduce(node: Node, _nodes: Node[]) { const data = node.data as NormNodeData; @@ -73,10 +73,37 @@ export function NormReduce(node: Node, _nodes: Node[]) { } /** - * This function is called whenever a connection is made with this node type (Norm) + * This function is called whenever a connection is made with this node type as the target * @param _thisNode the node of this node type which function is called - * @param _otherNode the other node which was part of the connection - * @param _isThisSource whether this instance of the node was the source in the connection, true = yes. + * @param _sourceNodeId the source of the received connection */ -export function NormConnects(_thisNode: Node, _otherNode: Node, _isThisSource: boolean) { +export function NormConnectionTarget(_thisNode: Node, _sourceNodeId: string) { + // no additional connection logic exists yet +} + +/** + * This function is called whenever a connection is made with this node type as the source + * @param _thisNode the node of this node type which function is called + * @param _targetNodeId the target of the created connection + */ +export function NormConnectionSource(_thisNode: Node, _targetNodeId: string) { + // no additional connection logic exists yet +} + +/** + * This function is called whenever a connection is disconnected with this node type as the target + * @param _thisNode the node of this node type which function is called + * @param _sourceNodeId the source of the disconnected connection + */ +export function NormDisconnectionTarget(_thisNode: Node, _sourceNodeId: string) { + // no additional connection logic exists yet +} + +/** + * This function is called whenever a connection is disconnected with this node type as the source + * @param _thisNode the node of this node type which function is called + * @param _targetNodeId the target of the diconnected connection + */ +export function NormDisconnectionSource(_thisNode: Node, _targetNodeId: string) { + // no additional connection logic exists yet } \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx index c8ea2c0..41679f1 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx @@ -2,11 +2,11 @@ import { Handle, type NodeProps, Position, - type Node, + type Node } from '@xyflow/react'; import { Toolbar } from '../components/NodeComponents'; import styles from '../../VisProg.module.css'; -import { NodeReduces, NodesInPhase, NodeTypes } from '../NodeRegistry'; +import { NodeReduces, NodesInPhase, NodeTypes} from '../NodeRegistry'; import useFlowStore from '../VisProgStores'; import { TextField } from '../../../../components/TextField'; @@ -104,14 +104,45 @@ export function PhaseReduce(node: Node, nodes: Node[]) { } /** - * This function is called whenever a connection is made with this node type (phase) - * @param thisNode the node of this node type which function is called - * @param otherNode the other node which was part of the connection - * @param isThisSource whether this instance of the node was the source in the connection, true = yes. + * This function is called whenever a connection is made with this node type as the target (phase) + * @param _thisNode the node of this node type which function is called + * @param _sourceNodeId the source of the received connection */ -export function PhaseConnects(thisNode: Node, otherNode: Node, isThisSource: boolean) { - const node = thisNode as PhaseNode +export function PhaseConnectionTarget(_thisNode: Node, _sourceNodeId: string) { + const node = _thisNode as PhaseNode const data = node.data as PhaseNodeData - if (!isThisSource) - data.children.push(otherNode.id) + // we only add none phase nodes to the children + if (!(useFlowStore.getState().nodes.find((node) => node.id === _sourceNodeId && node.type === 'phase'))) { + data.children.push(_sourceNodeId) + } + +} + +/** + * This function is called whenever a connection is made with this node type as the source (phase) + * @param _thisNode the node of this node type which function is called + * @param _targetNodeId the target of the created connection + */ +export function PhaseConnectionSource(_thisNode: Node, _targetNodeId: string) { + // no additional connection logic exists yet +} + +/** + * This function is called whenever a connection is disconnected with this node type as the target (phase) + * @param _thisNode the node of this node type which function is called + * @param _sourceNodeId the source of the disconnected connection + */ +export function PhaseDisconnectionTarget(_thisNode: Node, _sourceNodeId: string) { + const node = _thisNode as PhaseNode + const data = node.data as PhaseNodeData + data.children = data.children.filter((child) => { if (child != _sourceNodeId) return child; }); +} + +/** + * This function is called whenever a connection is disconnected with this node type as the source (phase) + * @param _thisNode the node of this node type which function is called + * @param _targetNodeId the target of the diconnected connection + */ +export function PhaseDisconnectionSource(_thisNode: Node, _targetNodeId: string) { + // no additional connection logic exists yet } \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx index f994090..92ca6ed 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx @@ -51,10 +51,37 @@ export function StartReduce(node: Node, _nodes: Node[]) { } /** - * This function is called whenever a connection is made with this node type (start) + * This function is called whenever a connection is made with this node type as the target * @param _thisNode the node of this node type which function is called - * @param _otherNode the other node which was part of the connection - * @param _isThisSource whether this instance of the node was the source in the connection, true = yes. + * @param _sourceNodeId the source of the received connection */ -export function StartConnects(_thisNode: Node, _otherNode: Node, _isThisSource: boolean) { +export function StartConnectionTarget(_thisNode: Node, _sourceNodeId: string) { + // no additional connection logic exists yet +} + +/** + * This function is called whenever a connection is made with this node type as the source + * @param _thisNode the node of this node type which function is called + * @param _targetNodeId the target of the created connection + */ +export function StartConnectionSource(_thisNode: Node, _targetNodeId: string) { + // no additional connection logic exists yet +} + +/** + * This function is called whenever a connection is disconnected with this node type as the target + * @param _thisNode the node of this node type which function is called + * @param _sourceNodeId the source of the disconnected connection + */ +export function StartDisconnectionTarget(_thisNode: Node, _sourceNodeId: string) { + // no additional connection logic exists yet +} + +/** + * This function is called whenever a connection is disconnected with this node type as the source + * @param _thisNode the node of this node type which function is called + * @param _targetNodeId the target of the diconnected connection + */ +export function StartDisconnectionSource(_thisNode: Node, _targetNodeId: string) { + // no additional connection logic exists yet } \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx index 2e7b732..cad7015 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx @@ -102,13 +102,39 @@ export function TriggerReduce(node: Node, _nodes: Node[]) { } /** - * This function is called whenever a connection is made with this node type (trigger) + * This function is called whenever a connection is made with this node type as the target * @param _thisNode the node of this node type which function is called - * @param _otherNode the other node which was part of the connection - * @param _isThisSource whether this instance of the node was the source in the connection, true = yes. + * @param _sourceNodeId the source of the received connection */ -export function TriggerConnects(_thisNode: Node, _otherNode: Node, _isThisSource: boolean) { - +export function TriggerConnectionTarget(_thisNode: Node, _sourceNodeId: string) { + // no additional connection logic exists yet +} + +/** + * This function is called whenever a connection is made with this node type as the source + * @param _thisNode the node of this node type which function is called + * @param _targetNodeId the target of the created connection + */ +export function TriggerConnectionSource(_thisNode: Node, _targetNodeId: string) { + // no additional connection logic exists yet +} + +/** + * This function is called whenever a connection is disconnected with this node type as the target + * @param _thisNode the node of this node type which function is called + * @param _sourceNodeId the source of the disconnected connection + */ +export function TriggerDisconnectionTarget(_thisNode: Node, _sourceNodeId: string) { + // no additional connection logic exists yet +} + +/** + * This function is called whenever a connection is disconnected with this node type as the source + * @param _thisNode the node of this node type which function is called + * @param _targetNodeId the target of the diconnected connection + */ +export function TriggerDisconnectionSource(_thisNode: Node, _targetNodeId: string) { + // no additional connection logic exists yet } // Definitions for the possible triggers, being keywords and emotions diff --git a/test/pages/visProgPage/visualProgrammingUI/VisProgStores.test.tsx b/test/pages/visProgPage/visualProgrammingUI/VisProgStores.test.tsx index 63fec3d..8ce8e18 100644 --- a/test/pages/visProgPage/visualProgrammingUI/VisProgStores.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/VisProgStores.test.tsx @@ -1,4 +1,7 @@ import {act} from '@testing-library/react'; +import type {Connection, Edge, Node} from "@xyflow/react"; +import { NodeDisconnections } from "../../../../src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts"; +import type {PhaseNodeData} from "../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx"; import useFlowStore from '../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx'; import { mockReactFlow } from '../../../setupFlowTests.ts'; @@ -6,18 +9,187 @@ beforeAll(() => { mockReactFlow(); }); +// default state values for testing, +const normNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Test Norm', + droppable: true, + norm: 'Test', + hasReduce: true, + }, +}; + +const phaseNode: Node = { + id: 'phase-1', + type: 'phase', + position: { x: 100, y: 0 }, + data: { + label: 'Phase 1', + droppable: true, + children: ["norm-1"], + hasReduce: true, + }, +}; + +const testEdge: Edge = { + id: 'xy-edge__1-2', + source: 'norm-1', + target: 'phase-1', + sourceHandle: null, + targetHandle: null, +} + +const testStateReconnectEnd = { + nodes: [phaseNode, normNode], + edges: [testEdge], +} + +const phaseNodeUnconnected = { + id: 'phase-2', + type: 'phase', + position: { x: 100, y: 0 }, + data: { + label: 'Phase 2', + droppable: true, + children: [], + hasReduce: true, + }, +}; + +const testConnection: Connection = { + source: 'norm-1', + target: 'phase-2', + sourceHandle: null, + targetHandle: null, +} +const testStateOnConnect = { + nodes: [phaseNodeUnconnected, normNode], + edges: [], +} + describe('FlowStore Functionality', () => { describe('Node changes', () => { // currently just using a single function from the ReactFlow library, // so testing would mean we are testing already tested behavior. // if implementation gets modified tests should be added for custom behavior }); + describe('ReactFlow onEdgesDelete', () => { + test('Deleted edge is reflected in removed phaseNode child', () => { + const {onEdgesDelete} = useFlowStore.getState(); + + useFlowStore.setState({ + nodes: [{ + id: 'phase-1', + type: 'phase', + position: { x: 100, y: 0 }, + data: { + label: 'Phase 1', + droppable: true, + children: ["norm-1"], + hasReduce: true, + }, + },{ + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Test Norm', + droppable: true, + norm: 'Test', + hasReduce: true, + }, + }], + edges: [], // edges is empty as onEdgesDelete is triggered after the edges are deleted + }) + + act(() => { + onEdgesDelete([testEdge]) + }); + + const outcome = useFlowStore.getState(); + expect((outcome.nodes[0].data as PhaseNodeData).children.length).toBe(0); + }) + test('Deleted edge is reflected in phaseNode,even if normNode was already deleted and caused edge removal', () => { + const { onEdgesDelete } = useFlowStore.getState(); + useFlowStore.setState({ + nodes: [{ + id: 'phase-1', + type: 'phase', + position: { x: 100, y: 0 }, + data: { + label: 'Phase 1', + droppable: true, + children: ["norm-1"], + hasReduce: true, + }, + }], + edges: [], // edges is empty as onEdgesDelete is triggered after the edges are deleted + }) + + act(() => { + onEdgesDelete([testEdge]); + }) + + const outcome = useFlowStore.getState(); + expect((outcome.nodes[0].data as PhaseNodeData).children.length).toBe(0); + }) + test('edge removal resulting from deletion of targetNode calls only the connection function for the sourceNode', () => { + const { onEdgesDelete } = useFlowStore.getState(); + useFlowStore.setState({ + nodes: [{ + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Test Norm', + droppable: true, + norm: 'Test', + hasReduce: true, + }, + }], + edges: [], // edges is empty as onEdgesDelete is triggered after the edges are deleted + }) + + const targetDisconnectSpy = jest.spyOn(NodeDisconnections.Targets, 'phase'); + const sourceDisconnectSpy = jest.spyOn(NodeDisconnections.Sources, 'norm'); + + act(() => { + onEdgesDelete([testEdge]); + }) + + expect(sourceDisconnectSpy).toHaveBeenCalledWith(normNode, 'phase-1'); + expect(targetDisconnectSpy).not.toHaveBeenCalled(); + + sourceDisconnectSpy.mockRestore(); + targetDisconnectSpy.mockRestore(); + }) + }) describe('Edge changes', () => { // currently just using a single function from the ReactFlow library, // so testing would mean we are testing already tested behavior. // if implementation gets modified tests should be added for custom behavior }) describe('ReactFlow onConnect', () => { + test('Adds connecting node to children of phaseNode', () => { + const {onConnect} = useFlowStore.getState(); + useFlowStore.setState({ + nodes: testStateOnConnect.nodes, + edges: testStateOnConnect.edges + }) + + act(() => { + onConnect(testConnection); + }) + + const outcome = useFlowStore.getState(); + + // phaseNode adds the normNode to its children + expect((outcome.nodes[0].data as PhaseNodeData).children).toEqual(['norm-1']); + + }) test('adds an edge when onConnect is triggered', () => { const {onConnect} = useFlowStore.getState(); @@ -39,6 +211,53 @@ describe('FlowStore Functionality', () => { }); }); describe('ReactFlow onReconnect', () => { + test('PhaseNodes correctly change their children', () => { + const {onReconnect} = useFlowStore.getState(); + useFlowStore.setState({ + nodes: [{ + id: 'phase-1', + type: 'phase', + position: { x: 100, y: 0 }, + data: { + label: 'Phase 1', + droppable: true, + children: ["norm-1"], + hasReduce: true, + }, + },{ + id: 'phase-2', + type: 'phase', + position: { x: 100, y: 0 }, + data: { + label: 'Phase 2', + droppable: true, + children: [], + hasReduce: true, + }, + },{ + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Test Norm', + droppable: true, + norm: 'Test', + hasReduce: true, + }, + }], + edges: [testEdge], + }) + + act(() => { + onReconnect(testEdge, testConnection); + }) + + const outcome = useFlowStore.getState(); + + // phaseNodes lose and gain children when norm node's connection is changed from phaseNode to PhaseNodeUnconnected + expect((outcome.nodes[1].data as PhaseNodeData).children).toEqual(['norm-1']); + expect((outcome.nodes[0].data as PhaseNodeData).children).toEqual([]); + }) test('reconnects an existing edge when onReconnect is triggered', () => { const {onReconnect} = useFlowStore.getState(); const oldEdge = { @@ -93,36 +312,63 @@ describe('FlowStore Functionality', () => { ); }); + + test('successfully removes edge if no successful reconnect occurred', () => { const {onReconnectEnd} = useFlowStore.getState(); - useFlowStore.setState({edgeReconnectSuccessful: false}); + useFlowStore.setState({ + edgeReconnectSuccessful: false, + edges: testStateReconnectEnd.edges, + nodes: testStateReconnectEnd.nodes + }); act(() => { - onReconnectEnd(null, {id: 'xy-edge__A-B'}); + onReconnectEnd(null, testEdge); }); const updatedState = useFlowStore.getState(); expect(updatedState.edgeReconnectSuccessful).toBe(true); expect(updatedState.edges).toHaveLength(0); + expect(updatedState.nodes[0].data.children).toEqual([]); }); test('does not remove reconnecting edge if successful reconnect occurred', () => { const {onReconnectEnd} = useFlowStore.getState(); + useFlowStore.setState({ + edgeReconnectSuccessful: true, + edges: [testEdge], + nodes: [{ + id: 'phase-1', + type: 'phase', + position: { x: 100, y: 0 }, + data: { + label: 'Phase 1', + droppable: true, + children: ["norm-1"], + hasReduce: true, + }, + },{ + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Test Norm', + droppable: true, + norm: 'Test', + hasReduce: true, + }, + }] + }); act(() => { - onReconnectEnd(null, {id: 'xy-edge__A-B'}); + onReconnectEnd(null, testEdge); }); const updatedState = useFlowStore.getState(); expect(updatedState.edgeReconnectSuccessful).toBe(true); expect(updatedState.edges).toHaveLength(1); - expect(updatedState.edges).toMatchObject([ - { - id: 'xy-edge__A-B', - source: 'A', - target: 'B' - }] - ); + expect(updatedState.edges).toMatchObject([testEdge]); + expect(updatedState.nodes[0].data.children).toEqual(["norm-1"]); }); }); describe('ReactFlow deleteNode', () => { diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx index 25c9947..45ae756 100644 --- a/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx @@ -1,8 +1,12 @@ 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 NormNode, { NormReduce, NormConnects, type NormNodeData } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode' +import { renderWithProviders } from '../../../../test-utils/test-utils.tsx'; +import NormNode, { + NormReduce, + type NormNodeData, + NormConnectionSource, NormConnectionTarget +} from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode'; import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores'; import type { Node } from '@xyflow/react'; import '@testing-library/jest-dom' @@ -517,7 +521,7 @@ describe('NormNode', () => { }; expect(() => { - NormConnects(normNode, phaseNode, true); + NormConnectionSource(normNode, phaseNode.id); }).not.toThrow(); }); @@ -547,7 +551,7 @@ describe('NormNode', () => { }; expect(() => { - NormConnects(normNode, phaseNode, false); + NormConnectionTarget(normNode, phaseNode.id); }).not.toThrow(); }); @@ -565,7 +569,8 @@ describe('NormNode', () => { }; expect(() => { - NormConnects(normNode, normNode, true); + NormConnectionTarget(normNode, normNode.id); + NormConnectionSource(normNode, normNode.id); }).not.toThrow(); }); }); diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/StartNode.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/StartNode.test.tsx index c5ec43a..f1d468d 100644 --- a/test/pages/visProgPage/visualProgrammingUI/nodes/StartNode.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/StartNode.test.tsx @@ -2,8 +2,11 @@ import { describe, it } from '@jest/globals'; import '@testing-library/jest-dom'; import { screen } from '@testing-library/react'; import type { Node } from '@xyflow/react'; -import { renderWithProviders } from '../.././/./../../test-utils/test-utils'; -import StartNode, { StartReduce, StartConnects } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode'; +import { renderWithProviders } from '../../../../test-utils/test-utils.tsx'; +import StartNode, { + StartConnectionSource, StartConnectionTarget, + StartReduce +} from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode'; describe('StartNode', () => { @@ -91,8 +94,8 @@ describe('StartNode', () => { }, }; - expect(() => StartConnects(startNode, otherNode, true)).not.toThrow(); - expect(() => StartConnects(startNode, otherNode, false)).not.toThrow(); + expect(() => StartConnectionSource(startNode, otherNode.id)).not.toThrow(); + expect(() => StartConnectionTarget(startNode, otherNode.id)).not.toThrow(); }); }); }); diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/TriggerNode.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/TriggerNode.test.tsx index 55a46e3..e3c40e0 100644 --- a/test/pages/visProgPage/visualProgrammingUI/nodes/TriggerNode.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/TriggerNode.test.tsx @@ -1,8 +1,13 @@ 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 TriggerNode, { TriggerReduce, TriggerConnects, TriggerNodeCanConnect, type TriggerNodeData } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode'; +import { renderWithProviders } from '../../../../test-utils/test-utils.tsx'; +import TriggerNode, { + TriggerReduce, + TriggerNodeCanConnect, + type TriggerNodeData, + TriggerConnectionSource, TriggerConnectionTarget +} from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode'; import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores'; import type { Node } from '@xyflow/react'; import '@testing-library/jest-dom'; @@ -233,8 +238,8 @@ describe('TriggerNode', () => { }; expect(() => { - TriggerConnects(node1, node2, true); - TriggerConnects(node1, node2, false); + TriggerConnectionSource(node1, node2.id); + TriggerConnectionTarget(node1, node2.id); }).not.toThrow(); }); diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx index 7fb0709..c6b9244 100644 --- a/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx @@ -1,8 +1,8 @@ import { describe, beforeEach } from '@jest/globals'; import { screen } from '@testing-library/react'; -import { renderWithProviders } from '../.././/./../../test-utils/test-utils'; +import { renderWithProviders } from '../../../../test-utils/test-utils.tsx'; import type { XYPosition } from '@xyflow/react'; -import { NodeTypes, NodeDefaults, NodeConnects, NodeReduces, NodesInPhase } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/NodeRegistry'; +import { NodeTypes, NodeDefaults, NodeConnections, NodeReduces, NodesInPhase } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/NodeRegistry'; import '@testing-library/jest-dom' import { createElement } from 'react'; import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores'; @@ -87,8 +87,8 @@ describe('NormNode', () => { useFlowStore.setState({ nodes: [sourceNode, targetNode] }); // Spy on the connect functions - const sourceConnectSpy = jest.spyOn(NodeConnects, nodeType as keyof typeof NodeConnects); - const targetConnectSpy = jest.spyOn(NodeConnects, 'end'); + const sourceConnectSpy = jest.spyOn(NodeConnections.Sources, nodeType as keyof typeof NodeConnections.Sources); + const targetConnectSpy = jest.spyOn(NodeConnections.Targets, 'end'); // Simulate connection useFlowStore.getState().onConnect({ @@ -99,8 +99,8 @@ describe('NormNode', () => { }); // Verify the connect functions were called - expect(sourceConnectSpy).toHaveBeenCalledWith(sourceNode, targetNode, true); - expect(targetConnectSpy).toHaveBeenCalledWith(targetNode, sourceNode, false); + expect(sourceConnectSpy).toHaveBeenCalledWith(sourceNode, targetNode.id); + expect(targetConnectSpy).toHaveBeenCalledWith(targetNode, sourceNode.id); sourceConnectSpy.mockRestore(); targetConnectSpy.mockRestore(); @@ -130,7 +130,7 @@ describe('NormNode', () => { expect(phaseReduceSpy).toHaveBeenCalledWith(phaseNode, [phaseNode, testNode]); // Check if this node type is in NodesInPhase and returns false const nodesInPhaseFunc = NodesInPhase[nodeType as keyof typeof NodesInPhase]; - if (nodesInPhaseFunc && nodesInPhaseFunc() === false && nodeType !== 'phase') { + if (nodesInPhaseFunc && !nodesInPhaseFunc() && nodeType !== 'phase') { // Node is NOT in phase, so it should NOT be called expect(nodeReduceSpy).not.toHaveBeenCalled(); } else { -- 2.49.1 From 9d4f10213e1011bd4486fef5f009f40255f41ed8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Mon, 15 Dec 2025 11:59:12 +0100 Subject: [PATCH 139/184] fix: update the recducer in phases to account for node-specific reducing ref: N25B-408 --- src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts | 2 +- src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts b/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts index 04dabf1..97f5de6 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts @@ -97,5 +97,5 @@ export const NodesInPhase = { start: () => false, end: () => false, phase: () => false, - // basic_belief: () => false, + basic_belief: () => false, } \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx index c8ea2c0..112cfbf 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx @@ -96,7 +96,7 @@ export function PhaseReduce(node: Node, nodes: Node[]) { console.warn(`No reducer found for node type ${type}`); result[type + "s"] = []; } else { - result[type + "s"] = typedChildren.map((child) => reducer(child, nodes)); + result[type + "s"] = typedChildren.map((child) => reducer(child as any, nodes)); } }); -- 2.49.1 From f22fe38e225234de561671a6464a895c8053637a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Mon, 15 Dec 2025 12:01:39 +0100 Subject: [PATCH 140/184] fix: revert the reduce change for eslint- might be done later in other way ref: N25B-408 --- .../VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx | 4 ++-- src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx index 317a1eb..4f73a2d 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx @@ -155,8 +155,8 @@ export default function BasicBeliefNode(props: NodeProps) { * @param _nodes - The list of all nodes in the current flow graph. * @returns A simplified object containing the node label and its list of BasicBeliefs. */ -export function BasicBeliefReduce(node: BasicBeliefNode, _nodes: Node[]) { - const data = node.data; +export function BasicBeliefReduce(node: Node, _nodes: Node[]) { + const data = node.data as BasicBeliefNodeData; return { id: node.id, type: data.belief.type, diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx index 112cfbf..c8ea2c0 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx @@ -96,7 +96,7 @@ export function PhaseReduce(node: Node, nodes: Node[]) { console.warn(`No reducer found for node type ${type}`); result[type + "s"] = []; } else { - result[type + "s"] = typedChildren.map((child) => reducer(child as any, nodes)); + result[type + "s"] = typedChildren.map((child) => reducer(child, nodes)); } }); -- 2.49.1 From 757435e9f83e7511f17a4544a6e168d9a6029b41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Mon, 15 Dec 2025 12:09:53 +0100 Subject: [PATCH 141/184] fix: fix the tests and creation of nodes. ref: N25B-408 --- .../visualProgrammingUI/VisProgStores.tsx | 22 ++++++++++--------- .../nodes/UniversalNodes.test.tsx | 16 ++++++++------ 2 files changed, 21 insertions(+), 17 deletions(-) diff --git a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx index 5154a8e..3952e72 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx @@ -23,17 +23,19 @@ import { UndoRedo } from "./EditorUndoRedo.ts"; * @param deletable - Optional flag to indicate if the node can be deleted (can be deleted by default). * @returns A fully initialized Node object ready to be added to the flow. */ -function createNode(id: string, type: string, position: XYPosition, data: Record, deletable? : boolean) { - const defaultData = NodeDefaults[type as keyof typeof NodeDefaults] - const newData = { - id: id, - type: type, - position: position, - data: data, - deletable: deletable, +function createNode(id: string, type: string, position: XYPosition, data: Record, deletable?: boolean) { + const defaultData = NodeDefaults[type as keyof typeof NodeDefaults] + return { + id, + type, + position, + deletable, + data: { + ...defaultData, + ...data, + }, + } } - return {...defaultData, ...newData} -} //* Initial nodes, created by using createNode. */ const initialNodes : Node[] = [ diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx index 7fb0709..ba19230 100644 --- a/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx @@ -15,14 +15,16 @@ describe('NormNode', () => { function createNode(id: string, type: string, position: XYPosition, data: Record, deletable?: boolean) { const defaultData = NodeDefaults[type as keyof typeof NodeDefaults] - const newData = { - id: id, - type: type, - position: position, - data: data, - deletable: deletable, + return { + id, + type, + position, + deletable, + data: { + ...defaultData, + ...data, + }, } - return {...defaultData, ...newData} } -- 2.49.1 From ae8ef317a4c814187ee994ca643a790abb280fa6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Mon, 15 Dec 2025 13:04:53 +0100 Subject: [PATCH 142/184] test: tests for belief node ref: N25B-408 --- .../nodes/BasicBeliefNode.tsx | 3 +- .../nodes/BeliefNode.test.tsx | 743 ++++++++++++++++++ 2 files changed, 744 insertions(+), 2 deletions(-) create mode 100644 test/pages/visProgPage/visualProgrammingUI/nodes/BeliefNode.test.tsx diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx index 4f73a2d..4942d48 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx @@ -46,8 +46,7 @@ export type BasicBeliefNode = Node * @param connection - The connection or edge being attempted to connect towards. * @returns `true` if the connection is defined; otherwise, `false`. */ -export function BasicBeliefNodeCanConnect(connection: Connection | Edge): boolean { - return (connection != undefined); +export function BasicBeliefNodeCanConnect(_connection: Connection | Edge) { } /** diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/BeliefNode.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/BeliefNode.test.tsx new file mode 100644 index 0000000..07a69ef --- /dev/null +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/BeliefNode.test.tsx @@ -0,0 +1,743 @@ +// BasicBeliefNode.test.tsx +import { describe, it, beforeEach } from '@jest/globals'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWithProviders } from '../.././/./../../test-utils/test-utils'; +import BasicBeliefNode, { type BasicBeliefNodeData } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode'; +import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores'; +import type { Node } from '@xyflow/react'; +import '@testing-library/jest-dom'; + +describe('BasicBeliefNode', () => { + let user: ReturnType; + + beforeEach(() => { + user = userEvent.setup(); + }); + + describe('Rendering', () => { + it('should render the basic belief node with keyword type by default', () => { + const mockNode: Node = { + id: 'belief-1', + type: 'basic_belief', + position: { x: 0, y: 0 }, + data: { + label: 'Belief', + droppable: true, + belief: { type: 'keyword', id: 'help', value: 'help', label: 'Keyword said:' }, + hasReduce: true, + }, + }; + + renderWithProviders( + + ); + + expect(screen.getByText('Belief:')).toBeInTheDocument(); + expect(screen.getByDisplayValue('Keyword said:')).toBeInTheDocument(); + expect(screen.getByDisplayValue('help')).toBeInTheDocument(); + }); + + it('should render with semantic belief type', () => { + const mockNode: Node = { + id: 'belief-2', + type: 'basic_belief', + position: { x: 0, y: 0 }, + data: { + label: 'Belief', + droppable: true, + belief: { type: 'semantic', id: 'test', value: 'test value', label: 'Detected with LLM:' }, + hasReduce: true, + }, + }; + + renderWithProviders( + + ); + + expect(screen.getByDisplayValue('Detected with LLM:')).toBeInTheDocument(); + expect(screen.getByDisplayValue('test value')).toBeInTheDocument(); + }); + + it('should render with object belief type', () => { + const mockNode: Node = { + id: 'belief-3', + type: 'basic_belief', + position: { x: 0, y: 0 }, + data: { + label: 'Belief', + droppable: true, + belief: { type: 'object', id: 'obj1', value: 'cup', label: 'Object found:' }, + hasReduce: true, + }, + }; + + renderWithProviders( + + ); + + expect(screen.getByDisplayValue('Object found:')).toBeInTheDocument(); + expect(screen.getByDisplayValue('cup')).toBeInTheDocument(); + }); + + it('should render with emotion belief type and select dropdown', () => { + const mockNode: Node = { + id: 'belief-4', + type: 'basic_belief', + position: { x: 0, y: 0 }, + data: { + label: 'Belief', + droppable: true, + belief: { type: 'emotion', id: 'em1', value: 'happy', label: 'Emotion recognised:' }, + hasReduce: true, + }, + }; + + renderWithProviders( + + ); + + expect(screen.getByDisplayValue('Emotion recognised:')).toBeInTheDocument(); + // For emotion type, we should check that the select has the correct value selected + const selectElement = screen.getByDisplayValue('Happy'); + expect(selectElement).toBeInTheDocument(); + expect((selectElement as HTMLSelectElement).value).toBe('happy'); + }); + + it('should render emotion dropdown with all emotion options', () => { + const mockNode: Node = { + id: 'belief-5', + type: 'basic_belief', + position: { x: 0, y: 0 }, + data: { + label: 'Belief', + droppable: true, + belief: { type: 'emotion', id: 'em1', value: 'happy', label: 'Emotion recognised:' }, + hasReduce: true, + }, + }; + + renderWithProviders( + + ); + + const selectElement = screen.getByDisplayValue('Happy'); + expect(selectElement).toBeInTheDocument(); + + // Check that all emotion options are present + expect(screen.getByText('Happy')).toBeInTheDocument(); + expect(screen.getByText('Angry')).toBeInTheDocument(); + expect(screen.getByText('Sad')).toBeInTheDocument(); + expect(screen.getByText('Cheerful')).toBeInTheDocument(); + }); + + it('should render without wrapping quotes for object type', () => { + const mockNode: Node = { + id: 'belief-6', + type: 'basic_belief', + position: { x: 0, y: 0 }, + data: { + label: 'Belief', + droppable: true, + belief: { type: 'object', id: 'obj1', value: 'chair', label: 'Object found:' }, + hasReduce: true, + }, + }; + + renderWithProviders( + + ); + + // Object type should not have wrapping quotes + const inputs = screen.getAllByDisplayValue('chair'); + expect(inputs.length).toBe(1); // Only the text input, no extra quote elements + }); + }); + + describe('User Interactions', () => { + it('should update belief type when select is changed', async () => { + const mockNode: Node = { + id: 'belief-1', + type: 'basic_belief', + position: { x: 0, y: 0 }, + data: { + label: 'Belief', + droppable: true, + belief: { type: 'keyword', id: 'kw1', value: 'hello', label: 'Keyword said:' }, + hasReduce: true, + }, + }; + + useFlowStore.setState({ + nodes: [mockNode], + edges: [], + }); + + renderWithProviders( + + ); + + const select = screen.getByDisplayValue('Keyword said:'); + await user.selectOptions(select, 'semantic'); + + await waitFor(() => { + const state = useFlowStore.getState(); + const updatedNode = state.nodes.find(n => n.id === 'belief-1') as Node; + expect(updatedNode?.data.belief.type).toBe('semantic'); + // Note: The component doesn't update the label when changing type + // So we can't test for label change + }); + }); + + it('should update text value when typing for keyword type', async () => { + const mockNode: Node = { + id: 'belief-1', + type: 'basic_belief', + position: { x: 0, y: 0 }, + data: { + label: 'Belief', + droppable: true, + belief: { type: 'keyword', id: 'kw1', value: '', label: 'Keyword said:' }, + hasReduce: true, + }, + }; + + useFlowStore.setState({ + nodes: [mockNode], + edges: [], + }); + + renderWithProviders( + + ); + + const input = screen.getByPlaceholderText('keyword...'); + await user.type(input, 'help me{enter}'); + + await waitFor(() => { + const state = useFlowStore.getState(); + const updatedNode = state.nodes.find(n => n.id === 'belief-1') as Node; + expect(updatedNode?.data.belief.value).toBe('help me'); + }); + }); + + it('should update text value when typing for semantic type', async () => { + const mockNode: Node = { + id: 'belief-1', + type: 'basic_belief', + position: { x: 0, y: 0 }, + data: { + label: 'Belief', + droppable: true, + belief: { type: 'semantic', id: 'sem1', value: 'initial', label: 'Detected with LLM:' }, + hasReduce: true, + }, + }; + + useFlowStore.setState({ + nodes: [mockNode], + edges: [], + }); + + renderWithProviders( + + ); + + const input = screen.getByDisplayValue('initial') as HTMLInputElement; + + // Clear the input + for (let i = 0; i < 'initial'.length; i++) { + await user.type(input, '{backspace}'); + } + await user.type(input, 'new semantic value{enter}'); + + await waitFor(() => { + const state = useFlowStore.getState(); + const updatedNode = state.nodes.find(n => n.id === 'belief-1') as Node; + expect(updatedNode?.data.belief.value).toBe('new semantic value'); + }); + }); + + it('should update emotion value when selecting from dropdown', async () => { + const mockNode: Node = { + id: 'belief-1', + type: 'basic_belief', + position: { x: 0, y: 0 }, + data: { + label: 'Belief', + droppable: true, + belief: { type: 'emotion', id: 'em1', value: 'happy', label: 'Emotion recognised:' }, + hasReduce: true, + }, + }; + + useFlowStore.setState({ + nodes: [mockNode], + edges: [], + }); + + renderWithProviders( + + ); + + const select = screen.getByDisplayValue('Happy'); + await user.selectOptions(select, 'sad'); + + await waitFor(() => { + const state = useFlowStore.getState(); + const updatedNode = state.nodes.find(n => n.id === 'belief-1') as Node; + expect(updatedNode?.data.belief.value).toBe('sad'); + }); + }); + + it('should preserve value when switching between text-based belief types', async () => { + const mockNode: Node = { + id: 'belief-1', + type: 'basic_belief', + position: { x: 0, y: 0 }, + data: { + label: 'Belief', + droppable: true, + belief: { type: 'keyword', id: 'kw1', value: 'test value', label: 'Keyword said:' }, + hasReduce: true, + }, + }; + + useFlowStore.setState({ + nodes: [mockNode], + edges: [], + }); + + renderWithProviders( + + ); + + // Switch from keyword to semantic + const typeSelect = screen.getByDisplayValue('Keyword said:'); + await user.selectOptions(typeSelect, 'semantic'); + + await waitFor(() => { + const state = useFlowStore.getState(); + const updatedNode = state.nodes.find(n => n.id === 'belief-1') as Node; + expect(updatedNode?.data.belief.type).toBe('semantic'); + expect(updatedNode?.data.belief.value).toBe('test value'); // Value should be preserved + }); + }); + + it('should preserve value when switching from text type to emotion type', async () => { + const mockNode: Node = { + id: 'belief-1', + type: 'basic_belief', + position: { x: 0, y: 0 }, + data: { + label: 'Belief', + droppable: true, + belief: { type: 'keyword', id: 'kw1', value: 'some text', label: 'Keyword said:' }, + hasReduce: true, + }, + }; + + useFlowStore.setState({ + nodes: [mockNode], + edges: [], + }); + + renderWithProviders( + + ); + + // Switch from keyword to emotion + const typeSelect = screen.getByDisplayValue('Keyword said:'); + await user.selectOptions(typeSelect, 'emotion'); + + await waitFor(() => { + const state = useFlowStore.getState(); + const updatedNode = state.nodes.find(n => n.id === 'belief-1') as Node; + 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('some text'); + }); + }); + }); + + // ... 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 = { + id: 'belief-1', + type: 'basic_belief', + position: { x: 0, y: 0 }, + data: { + label: 'Belief', + droppable: true, + belief: { type: 'keyword', id: 'kw1', value: '', label: 'Keyword said:' }, + hasReduce: true, + }, + }; + + useFlowStore.setState({ + nodes: [mockNode], + edges: [], + }); + + renderWithProviders( + + ); + + const input = screen.getByPlaceholderText('keyword...'); + await user.type(input, 'emergency{enter}'); + + await waitFor(() => { + const state = useFlowStore.getState(); + expect(state.nodes).toHaveLength(1); + expect(state.nodes[0].id).toBe('belief-1'); + const beliefData = state.nodes[0].data as BasicBeliefNodeData; + expect(beliefData.belief.value).toBe('emergency'); + expect(beliefData.belief.type).toBe('keyword'); + }); + }); + + it('should properly update the store when changing belief type', async () => { + const mockNode: Node = { + id: 'belief-1', + type: 'basic_belief', + position: { x: 0, y: 0 }, + data: { + label: 'Belief', + droppable: true, + belief: { type: 'keyword', id: 'kw1', value: 'test', label: 'Keyword said:' }, + hasReduce: true, + }, + }; + + useFlowStore.setState({ + nodes: [mockNode], + edges: [], + }); + + renderWithProviders( + + ); + + const select = screen.getByDisplayValue('Keyword said:'); + await user.selectOptions(select, 'object'); + + await waitFor(() => { + const state = useFlowStore.getState(); + const beliefData = state.nodes[0].data as BasicBeliefNodeData; + expect(beliefData.belief.type).toBe('object'); + // Note: The component doesn't update the label when changing type + expect(beliefData.belief.value).toBe('test'); // Value should be preserved + }); + }); + + it('should not affect other nodes when updating one belief node', async () => { + const belief1: Node = { + id: 'belief-1', + type: 'basic_belief', + position: { x: 0, y: 0 }, + data: { + label: 'Belief 1', + droppable: true, + belief: { type: 'keyword', id: 'kw1', value: 'hello', label: 'Keyword said:' }, + hasReduce: true, + }, + }; + + const belief2: Node = { + id: 'belief-2', + type: 'basic_belief', + position: { x: 100, y: 0 }, + data: { + label: 'Belief 2', + droppable: true, + belief: { type: 'object', id: 'obj1', value: 'chair', label: 'Object found:' }, + hasReduce: true, + }, + }; + + useFlowStore.setState({ + nodes: [belief1, belief2], + edges: [], + }); + + renderWithProviders( + + ); + + const input = screen.getByDisplayValue('hello') as HTMLInputElement; + + // Clear the input + for (let i = 0; i < 'hello'.length; i++) { + await user.type(input, '{backspace}'); + } + await user.type(input, 'goodbye{enter}'); + + await waitFor(() => { + const state = useFlowStore.getState(); + const updatedBelief1 = state.nodes.find(n => n.id === 'belief-1') as Node; + const unchangedBelief2 = state.nodes.find(n => n.id === 'belief-2') as Node; + + expect(updatedBelief1.data.belief.value).toBe('goodbye'); + expect(unchangedBelief2.data.belief.value).toBe('chair'); + expect(unchangedBelief2.data.belief.type).toBe('object'); + }); + }); + + it('should handle multiple rapid updates to belief value', async () => { + const mockNode: Node = { + id: 'belief-1', + type: 'basic_belief', + position: { x: 0, y: 0 }, + data: { + label: 'Belief', + droppable: true, + belief: { type: 'semantic', id: 'sem1', value: 'initial', label: 'Detected with LLM:' }, + hasReduce: true, + }, + }; + + useFlowStore.setState({ + nodes: [mockNode], + edges: [], + }); + + renderWithProviders( + + ); + + const input = screen.getByDisplayValue('initial') as HTMLInputElement; + + await user.type(input, '1'); + await waitFor(() => { + const state = useFlowStore.getState(); + const nodeData = state.nodes[0].data as BasicBeliefNodeData; + expect(nodeData.belief.value).toBe('initial'); + }); + + await user.type(input, '2'); + await waitFor(() => { + const state = useFlowStore.getState(); + const nodeData = state.nodes[0].data as BasicBeliefNodeData; + expect(nodeData.belief.value).toBe('initial'); + }); + + await user.type(input, '{enter}'); + await waitFor(() => { + const state = useFlowStore.getState(); + const nodeData = state.nodes[0].data as BasicBeliefNodeData; + expect(nodeData.belief.value).toBe('initial12'); + }); + }); + }); +}); \ No newline at end of file -- 2.49.1 From 7925023f25984f79390a9548340143b600863759 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Mon, 15 Dec 2025 14:51:58 +0100 Subject: [PATCH 143/184] fix: fix issues ariving from dev merge ref: N25B-408 --- .../visualProgrammingUI/NodeRegistry.ts | 6 ++- .../nodes/BasicBeliefNode.tsx | 39 +++++++++++++++---- 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts b/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts index d142663..023440c 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts @@ -46,7 +46,7 @@ import TriggerNode, { TriggerReduce } from "./nodes/TriggerNode"; import { TriggerNodeDefaults } from "./nodes/TriggerNode.default"; -import BasicBeliefNode, { BasicBeliefConnects, BasicBeliefReduce } from "./nodes/BasicBeliefNode"; +import BasicBeliefNode, { BasicBeliefConnectionSource, BasicBeliefConnectionTarget, BasicBeliefDisconnectionSource, BasicBeliefDisconnectionTarget, BasicBeliefReduce } from "./nodes/BasicBeliefNode"; import { BasicBeliefNodeDefaults } from "./nodes/BasicBeliefNode.default"; /** @@ -112,6 +112,7 @@ export const NodeConnections = { norm: NormConnectionTarget, goal: GoalConnectionTarget, trigger: TriggerConnectionTarget, + basic_belief: BasicBeliefConnectionTarget, }, Sources: { start: StartConnectionSource, @@ -120,6 +121,7 @@ export const NodeConnections = { norm: NormConnectionSource, goal: GoalConnectionSource, trigger: TriggerConnectionSource, + basic_belief: BasicBeliefConnectionSource } } @@ -137,6 +139,7 @@ export const NodeDisconnections = { norm: NormDisconnectionTarget, goal: GoalDisconnectionTarget, trigger: TriggerDisconnectionTarget, + basic_belief: BasicBeliefDisconnectionTarget, }, Sources: { start: StartDisconnectionSource, @@ -145,6 +148,7 @@ export const NodeDisconnections = { norm: NormDisconnectionSource, goal: GoalDisconnectionSource, trigger: TriggerDisconnectionSource, + basic_belief: BasicBeliefDisconnectionSource, }, } diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx index 4942d48..a8f7ceb 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx @@ -2,8 +2,6 @@ import { Handle, type NodeProps, Position, - type Connection, - type Edge, type Node, } from '@xyflow/react'; import { Toolbar } from '../components/NodeComponents'; @@ -41,12 +39,39 @@ export type BasicBeliefNode = Node /** - * Determines whether a BasicBelief node can connect to another node or edge. - * - * @param connection - The connection or edge being attempted to connect towards. - * @returns `true` if the connection is defined; otherwise, `false`. + * This function is called whenever a connection is made with this node type as the target + * @param _thisNode the node of this node type which function is called + * @param _sourceNodeId the source of the received connection */ -export function BasicBeliefNodeCanConnect(_connection: Connection | Edge) { +export function BasicBeliefConnectionTarget(_thisNode: Node, _sourceNodeId: string) { + // no additional connection logic exists yet +} + +/** + * This function is called whenever a connection is made with this node type as the source + * @param _thisNode the node of this node type which function is called + * @param _targetNodeId the target of the created connection + */ +export function BasicBeliefConnectionSource(_thisNode: Node, _targetNodeId: string) { + // no additional connection logic exists yet +} + +/** + * This function is called whenever a connection is disconnected with this node type as the target + * @param _thisNode the node of this node type which function is called + * @param _sourceNodeId the source of the disconnected connection + */ +export function BasicBeliefDisconnectionTarget(_thisNode: Node, _sourceNodeId: string) { + // no additional connection logic exists yet +} + +/** + * This function is called whenever a connection is disconnected with this node type as the source + * @param _thisNode the node of this node type which function is called + * @param _targetNodeId the target of the diconnected connection + */ +export function BasicBeliefDisconnectionSource(_thisNode: Node, _targetNodeId: string) { + // no additional connection logic exists yet } /** -- 2.49.1 From 8d4c3fc64b4a5f72ab881718726af829d96d9250 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Tue, 16 Dec 2025 12:03:48 +0100 Subject: [PATCH 144/184] feat: add conditions and beliefs, add tests ref: N25B-392 --- .../nodes/NormNode.default.ts | 1 + .../visualProgrammingUI/nodes/NormNode.tsx | 45 ++++- .../nodes/NormNode.test.tsx | 178 ++++++++++++++++-- 3 files changed, 201 insertions(+), 23 deletions(-) diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.default.ts b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.default.ts index 4b4a3ed..8df25cc 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.default.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.default.ts @@ -6,6 +6,7 @@ import type { NormNodeData } from "./NormNode"; export const NormNodeDefaults: NormNodeData = { label: "Norm Node", droppable: true, + conditions: [], norm: "", hasReduce: true, critical: false, diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx index 348b95d..371ab77 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx @@ -8,6 +8,7 @@ import { Toolbar } from '../components/NodeComponents'; import styles from '../../VisProg.module.css'; import { TextField } from '../../../../components/TextField'; import useFlowStore from '../VisProgStores'; +import { BasicBeliefReduce } from './BasicBeliefNode'; /** * The default data dot a phase node @@ -19,6 +20,7 @@ import useFlowStore from '../VisProgStores'; export type NormNodeData = { label: string; droppable: boolean; + conditions: string[]; // List of (basic) belief nodes' ids. norm: string; hasReduce: boolean; critical: boolean; @@ -67,7 +69,14 @@ export default function NormNode(props: NodeProps) { onChange={(e) => setAchieved(e.target.checked)} />
    + + {data.conditions.length > 0 && (
    + +
    )} + + +
    ; }; @@ -78,14 +87,29 @@ export default function NormNode(props: NodeProps) { * @param node The Node Properties of this node. * @param _nodes all the nodes in the graph */ -export function NormReduce(node: Node, _nodes: Node[]) { +export function NormReduce(node: Node, nodes: Node[]) { const data = node.data as NormNodeData; - return { - id: node.id, - label: data.label, - norm: data.norm, - critical: data.critical, - } + + // conditions nodes - make sure to check for empty arrays + let conditionNodes: Node[] = []; + if (data.conditions) + conditionNodes = nodes.filter((node) => data.conditions.includes(node.id)); + + // Build the result object + const result: Record = { + id: node.id, + label: data.label, + norm: data.norm, + critical: data.critical, + }; + + // Go over our conditionNodes. They should either be Basic (OR TODO: Inferred) + const reducer = BasicBeliefReduce; + result["basic_beliefs"] = conditionNodes.map((condition) => reducer(condition, nodes)) + + // When the Inferred is being implemented, you should follow the same kind of structure that PhaseNode has, + // dividing the conditions into basic and inferred, then calling the correct reducer on them. + return result } /** @@ -94,7 +118,12 @@ export function NormReduce(node: Node, _nodes: Node[]) { * @param _sourceNodeId the source of the received connection */ export function NormConnectionTarget(_thisNode: Node, _sourceNodeId: string) { - // no additional connection logic exists yet + const data = _thisNode.data as NormNodeData; + + // If we got a belief connected, this is a condition for the norm. + if ((useFlowStore.getState().nodes.find((node) => node.id === _sourceNodeId && node.type === 'basic_belief' /* TODO: Add the option for an inferred belief */))) { + data.conditions.push(_sourceNodeId); + } } /** diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx index a9848b2..272efbc 100644 --- a/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx @@ -10,8 +10,9 @@ import NormNode, { import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores'; import type { Node } from '@xyflow/react'; import '@testing-library/jest-dom' - - +import { NormNodeDefaults } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.default.ts'; +import { BasicBeliefNodeDefaults } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.default.ts'; +import BasicBeliefNode, { BasicBeliefConnectionSource } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx'; describe('NormNode', () => { let user: ReturnType; @@ -26,12 +27,7 @@ describe('NormNode', () => { id: 'norm-1', type: 'norm', position: { x: 0, y: 0 }, - data: { - label: 'Test Norm', - droppable: true, - norm: '', - hasReduce: true, - }, + data: NormNodeDefaults, }; renderWithProviders( @@ -60,6 +56,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { + ...NormNodeDefaults, label: 'Test Norm', droppable: true, norm: 'Be respectful to humans', @@ -94,8 +91,10 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { + ...NormNodeDefaults, label: 'Test Norm', droppable: true, + conditions: [], norm: '', hasReduce: true, critical: false @@ -129,6 +128,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { + ...NormNodeDefaults, label: 'Test Norm', droppable: true, norm: 'Dragged norm', @@ -165,6 +165,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { + ...NormNodeDefaults, label: 'Test Norm', droppable: true, norm: '', @@ -210,6 +211,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { + ...NormNodeDefaults, label: 'Test Norm', droppable: true, norm: 'Initial norm text', @@ -261,6 +263,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { + ...NormNodeDefaults, label: 'Test Norm', droppable: true, norm: '', @@ -314,6 +317,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { + ...NormNodeDefaults, label: 'Test Norm', droppable: true, norm: '', @@ -358,6 +362,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { + ...NormNodeDefaults, label: 'Test Norm', droppable: true, norm: '', @@ -404,6 +409,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { + ...NormNodeDefaults, label: 'Safety Norm', droppable: true, norm: 'Never harm humans', @@ -418,6 +424,8 @@ describe('NormNode', () => { id: 'norm-1', label: 'Safety Norm', norm: 'Never harm humans', + critical: false, + basic_beliefs: [], }); }); @@ -427,6 +435,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { + ...NormNodeDefaults, label: 'Norm 1', droppable: true, norm: 'Be helpful', @@ -439,6 +448,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 100, y: 0 }, data: { + ...NormNodeDefaults, label: 'Norm 2', droppable: true, norm: 'Be honest', @@ -463,6 +473,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { + ...NormNodeDefaults, label: 'Empty Norm', droppable: true, norm: '', @@ -482,6 +493,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { + ...NormNodeDefaults, label: 'Custom Label', droppable: false, norm: 'Test norm', @@ -502,6 +514,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { + ...NormNodeDefaults, label: 'Test Norm', droppable: true, norm: 'Test', @@ -514,6 +527,7 @@ describe('NormNode', () => { type: 'phase', position: { x: 100, y: 0 }, data: { + ...NormNodeDefaults, label: 'Phase 1', droppable: true, children: [], @@ -532,6 +546,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { + ...NormNodeDefaults, label: 'Test Norm', droppable: true, norm: 'Test', @@ -544,6 +559,7 @@ describe('NormNode', () => { type: 'phase', position: { x: 100, y: 0 }, data: { + ...NormNodeDefaults, label: 'Phase 1', droppable: true, children: [], @@ -562,6 +578,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { + ...NormNodeDefaults, label: 'Test Norm', droppable: true, norm: 'Test', @@ -583,6 +600,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { + ...NormNodeDefaults, label: 'Test Norm', droppable: true, norm: '', @@ -634,6 +652,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { + ...NormNodeDefaults, label: 'Test Norm', droppable: true, norm: '', @@ -682,6 +701,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { + ...NormNodeDefaults, label: 'Norm 1', droppable: true, norm: 'Original norm 1', @@ -694,6 +714,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 100, y: 0 }, data: { + ...NormNodeDefaults, label: 'Norm 2', droppable: true, norm: 'Original norm 2', @@ -748,6 +769,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { + ...NormNodeDefaults, label: 'Test Norm', droppable: true, norm: 'haa haa fuyaaah - link', @@ -778,21 +800,147 @@ describe('NormNode', () => { ); const input = screen.getByPlaceholderText('Pepper should ...'); + expect(input).toBeDefined() - await user.type(input, 'a'); + await user.type(input, 'a{enter}'); await waitFor(() => { - expect(useFlowStore.getState().nodes[0].data.norm).toBe('haa haa fuyaaah - link'); + expect(useFlowStore.getState().nodes[0].data.norm).toBe('haa haa fuyaaah - linka'); }); - await user.type(input, 'b'); + await user.type(input, 'b{enter}'); await waitFor(() => { - expect(useFlowStore.getState().nodes[0].data.norm).toBe('haa haa fuyaaah - link'); + expect(useFlowStore.getState().nodes[0].data.norm).toBe('haa haa fuyaaah - linkab'); }); - await user.type(input, 'c'); + await user.type(input, 'c{enter}'); await waitFor(() => { - expect(useFlowStore.getState().nodes[0].data.norm).toBe('haa haa fuyaaah - link'); - }, { timeout: 3000 }); + expect(useFlowStore.getState().nodes[0].data.norm).toBe('haa haa fuyaaah - linkabc'); + }); }); }); + + describe('Integration beliefs', () => { + it('should update visually when adding beliefs', async () => { + // Setup state + const mockNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + ...NormNodeDefaults, + label: 'Test Norm', + droppable: true, + norm: 'haa haa fuyaaah - link', + hasReduce: true, + } + }; + + const mockBelief: Node = { + id: 'basic_belief-1', + type: 'basic_belief', + position: {x:100, y:100}, + data: { + ...BasicBeliefNodeDefaults + } + }; + + useFlowStore.setState({ + nodes: [mockNode, mockBelief], + edges: [], + }); + + // Simulate connecting + NormConnectionTarget(mockNode, mockBelief.id); + BasicBeliefConnectionSource(mockBelief, mockNode.id) + + renderWithProviders( +
    + + +
    + ); + + await waitFor(() => { + expect(screen.getByTestId('norm-condition-information')).toBeInTheDocument(); + }); + }); + + it('should update the data when adding beliefs', async () => { + // Setup state + const mockNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + ...NormNodeDefaults, + label: 'Test Norm', + droppable: true, + norm: 'haa haa fuyaaah - link', + hasReduce: true, + } + }; + + const mockBelief1: Node = { + id: 'basic_belief-1', + type: 'basic_belief', + position: {x:100, y:100}, + data: { + ...BasicBeliefNodeDefaults + } + }; + + const mockBelief2: Node = { + id: 'basic_belief-2', + type: 'basic_belief', + position: {x:300, y:300}, + data: { + ...BasicBeliefNodeDefaults + } + }; + + useFlowStore.setState({ + nodes: [mockNode, mockBelief1, mockBelief2], + edges: [], + }); + + // Simulate connecting + NormConnectionTarget(mockNode, mockBelief1.id); + NormConnectionTarget(mockNode, mockBelief2.id); + BasicBeliefConnectionSource(mockBelief1, mockNode.id); + BasicBeliefConnectionSource(mockBelief2, mockNode.id); + + const state = useFlowStore.getState(); + const updatedNorm = state.nodes.find(n => n.id === 'norm-1'); + expect(updatedNorm?.data.conditions).toBe(["basic_belief-1", "basic_belief-2"]); + }); + + + + }); }); \ No newline at end of file -- 2.49.1 From c25073f20d94f7d41c99a5df4ad238d29fbd8bc2 Mon Sep 17 00:00:00 2001 From: "Luijkx,S.O.H. (Storm)" Date: Tue, 16 Dec 2025 11:26:35 +0000 Subject: [PATCH 145/184] feat: implemented extra log level for LLM token stream --- src/components/Logging/Filters.tsx | 1 + src/components/Logging/useLogs.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/Logging/Filters.tsx b/src/components/Logging/Filters.tsx index b98cc52..4b95de6 100644 --- a/src/components/Logging/Filters.tsx +++ b/src/components/Logging/Filters.tsx @@ -15,6 +15,7 @@ type Setter = (value: T | ((prev: T) => T)) => void; */ const optionMapping = new Map([ ["ALL", 0], + ["LLM", 9], ["DEBUG", 10], ["INFO", 20], ["WARNING", 30], diff --git a/src/components/Logging/useLogs.ts b/src/components/Logging/useLogs.ts index d51fdcb..752a4ab 100644 --- a/src/components/Logging/useLogs.ts +++ b/src/components/Logging/useLogs.ts @@ -19,7 +19,7 @@ import {cell, type Cell} from "../../utils/cellStore.ts"; export type LogRecord = { name: string; message: string; - levelname: 'DEBUG' | 'INFO' | 'WARNING' | 'ERROR' | 'CRITICAL' | string; + levelname: 'LLM' | 'DEBUG' | 'INFO' | 'WARNING' | 'ERROR' | 'CRITICAL' | string; levelno: number; created: number; relativeCreated: number; -- 2.49.1 From 099afebe98ffb1f2471613c40fb41eb659980023 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Tue, 16 Dec 2025 14:31:00 +0100 Subject: [PATCH 146/184] test: extra norm tests ref: N25B-392 --- .../visualProgrammingUI/VisProgStores.tsx | 2 +- .../visualProgrammingUI/nodes/NormNode.tsx | 6 ++- .../nodes/NormNode.test.tsx | 20 ++++++-- .../nodes/UniversalNodes.test.tsx | 46 ++++++++++++++++++- 4 files changed, 66 insertions(+), 8 deletions(-) diff --git a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx index 0847945..1decf8e 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx @@ -36,7 +36,7 @@ function createNode(id: string, type: string, position: XYPosition, data: Record position, deletable, data: { - ...defaultData, + ...JSON.parse(JSON.stringify(defaultData)), ...data, }, } diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx index 371ab77..bba42d0 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx @@ -141,7 +141,11 @@ export function NormConnectionSource(_thisNode: Node, _targetNodeId: string) { * @param _sourceNodeId the source of the disconnected connection */ export function NormDisconnectionTarget(_thisNode: Node, _sourceNodeId: string) { - // no additional connection logic exists yet + const data = _thisNode.data as NormNodeData; + // If we got a belief connected, this is a condition for the norm. + if ((useFlowStore.getState().nodes.find((node) => node.id === _sourceNodeId && node.type === 'basic_belief' /* TODO: Add the option for an inferred belief */))) { + data.conditions = data.conditions.filter(id => id != _sourceNodeId); + } } /** diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx index 272efbc..91c9962 100644 --- a/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx @@ -930,14 +930,24 @@ describe('NormNode', () => { }); // Simulate connecting - NormConnectionTarget(mockNode, mockBelief1.id); - NormConnectionTarget(mockNode, mockBelief2.id); - BasicBeliefConnectionSource(mockBelief1, mockNode.id); - BasicBeliefConnectionSource(mockBelief2, mockNode.id); + useFlowStore.getState().onConnect({ + source: 'basic_belief-1', + target: 'norm-1', + sourceHandle: null, + targetHandle: null, + }); + useFlowStore.getState().onConnect({ + source: 'basic_belief-2', + target: 'norm-1', + sourceHandle: null, + targetHandle: null, + }); + const state = useFlowStore.getState(); const updatedNorm = state.nodes.find(n => n.id === 'norm-1'); - expect(updatedNorm?.data.conditions).toBe(["basic_belief-1", "basic_belief-2"]); + console.log(updatedNorm?.data.conditions); + expect(updatedNorm?.data.conditions).toEqual(["basic_belief-1", "basic_belief-1", "basic_belief-2"]); }); diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx index c023722..4491388 100644 --- a/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx @@ -8,7 +8,7 @@ import { createElement } from 'react'; import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores'; -describe('NormNode', () => { +describe('Universal Nodes', () => { beforeEach(() => { jest.clearAllMocks(); }); @@ -109,6 +109,50 @@ describe('NormNode', () => { }); }); + describe('Disconnecting', () => { + test.each(getAllTypes())('it should remove the correct data when something is disconnected on a %s node.', (nodeType) => { + // Create two nodes - one of the current type and one to connect to + const sourceNode = createNode('source-1', nodeType, {x: 100, y: 100}, {}); + const targetNode = createNode('target-1', 'basic_belief', {x: 300, y: 100}, {}); + + // Add nodes to store + useFlowStore.setState({ nodes: [sourceNode, targetNode] }); + + // Spy on the connect functions + const sourceConnectSpy = jest.spyOn(NodeConnections.Sources, nodeType as keyof typeof NodeConnections.Sources); + const targetConnectSpy = jest.spyOn(NodeConnections.Targets, 'basic_belief'); + + // Simulate connection + useFlowStore.getState().onConnect({ + source: 'source-1', + target: 'target-1', + sourceHandle: null, + targetHandle: null, + }); + + + // Verify the connect functions were called + expect(sourceConnectSpy).toHaveBeenCalledWith(sourceNode, targetNode.id); + expect(targetConnectSpy).toHaveBeenCalledWith(targetNode, sourceNode.id); + + // Find this connection, and delete it + const edge = useFlowStore.getState().edges[0]; + useFlowStore.getState().onEdgesDelete([edge]); + + // Find the nodes in the flow + const newSourceNode = useFlowStore.getState().nodes.find((node) => node.id == "source-1"); + const newTargetNode = useFlowStore.getState().nodes.find((node) => node.id == "target-1"); + + // Expect them to be the same after deleting the edges + expect(newSourceNode).toBe(sourceNode); + expect(newTargetNode).toBe(targetNode); + + // Restore our spies + sourceConnectSpy.mockRestore(); + targetConnectSpy.mockRestore(); + }); + }); + describe('Reducing', () => { test.each(getAllTypes())('it should correctly call/ not call the reduce function when %s node is in a phase', (nodeType) => { // Create a phase node and a node of the current type -- 2.49.1 From 709dd28959d9fba28873a18c6da4942ae6a190cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Tue, 16 Dec 2025 14:52:57 +0100 Subject: [PATCH 147/184] fix: fixing the tests ref: N25B-392 --- .../visualProgrammingUI/nodes/NormNode.tsx | 1 - .../nodes/NormNode.test.tsx | 58 ++++++++++--------- .../nodes/UniversalNodes.test.tsx | 2 +- 3 files changed, 31 insertions(+), 30 deletions(-) diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx index bba42d0..36217e2 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx @@ -119,7 +119,6 @@ export function NormReduce(node: Node, nodes: Node[]) { */ export function NormConnectionTarget(_thisNode: Node, _sourceNodeId: string) { const data = _thisNode.data as NormNodeData; - // If we got a belief connected, this is a condition for the norm. if ((useFlowStore.getState().nodes.find((node) => node.id === _sourceNodeId && node.type === 'basic_belief' /* TODO: Add the option for an inferred belief */))) { data.conditions.push(_sourceNodeId); diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx index 91c9962..f003547 100644 --- a/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx @@ -27,7 +27,7 @@ describe('NormNode', () => { id: 'norm-1', type: 'norm', position: { x: 0, y: 0 }, - data: NormNodeDefaults, + data: {...JSON.parse(JSON.stringify(NormNodeDefaults))}, }; renderWithProviders( @@ -56,7 +56,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { - ...NormNodeDefaults, + ...JSON.parse(JSON.stringify(NormNodeDefaults)), label: 'Test Norm', droppable: true, norm: 'Be respectful to humans', @@ -91,7 +91,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { - ...NormNodeDefaults, + ...JSON.parse(JSON.stringify(NormNodeDefaults)), label: 'Test Norm', droppable: true, conditions: [], @@ -128,7 +128,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { - ...NormNodeDefaults, + ...JSON.parse(JSON.stringify(NormNodeDefaults)), label: 'Test Norm', droppable: true, norm: 'Dragged norm', @@ -165,7 +165,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { - ...NormNodeDefaults, + ...JSON.parse(JSON.stringify(NormNodeDefaults)), label: 'Test Norm', droppable: true, norm: '', @@ -211,7 +211,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { - ...NormNodeDefaults, + ...JSON.parse(JSON.stringify(NormNodeDefaults)), label: 'Test Norm', droppable: true, norm: 'Initial norm text', @@ -263,7 +263,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { - ...NormNodeDefaults, + ...JSON.parse(JSON.stringify(NormNodeDefaults)), label: 'Test Norm', droppable: true, norm: '', @@ -317,7 +317,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { - ...NormNodeDefaults, + ...JSON.parse(JSON.stringify(NormNodeDefaults)), label: 'Test Norm', droppable: true, norm: '', @@ -362,7 +362,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { - ...NormNodeDefaults, + ...JSON.parse(JSON.stringify(NormNodeDefaults)), label: 'Test Norm', droppable: true, norm: '', @@ -409,7 +409,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { - ...NormNodeDefaults, + ...JSON.parse(JSON.stringify(NormNodeDefaults)), label: 'Safety Norm', droppable: true, norm: 'Never harm humans', @@ -435,7 +435,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { - ...NormNodeDefaults, + ...JSON.parse(JSON.stringify(NormNodeDefaults)), label: 'Norm 1', droppable: true, norm: 'Be helpful', @@ -448,7 +448,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 100, y: 0 }, data: { - ...NormNodeDefaults, + ...JSON.parse(JSON.stringify(NormNodeDefaults)), label: 'Norm 2', droppable: true, norm: 'Be honest', @@ -473,7 +473,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { - ...NormNodeDefaults, + ...JSON.parse(JSON.stringify(NormNodeDefaults)), label: 'Empty Norm', droppable: true, norm: '', @@ -493,7 +493,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { - ...NormNodeDefaults, + ...JSON.parse(JSON.stringify(NormNodeDefaults)), label: 'Custom Label', droppable: false, norm: 'Test norm', @@ -514,7 +514,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { - ...NormNodeDefaults, + ...JSON.parse(JSON.stringify(NormNodeDefaults)), label: 'Test Norm', droppable: true, norm: 'Test', @@ -527,7 +527,7 @@ describe('NormNode', () => { type: 'phase', position: { x: 100, y: 0 }, data: { - ...NormNodeDefaults, + ...JSON.parse(JSON.stringify(NormNodeDefaults)), label: 'Phase 1', droppable: true, children: [], @@ -546,7 +546,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { - ...NormNodeDefaults, + ...JSON.parse(JSON.stringify(NormNodeDefaults)), label: 'Test Norm', droppable: true, norm: 'Test', @@ -559,7 +559,7 @@ describe('NormNode', () => { type: 'phase', position: { x: 100, y: 0 }, data: { - ...NormNodeDefaults, + ...JSON.parse(JSON.stringify(NormNodeDefaults)), label: 'Phase 1', droppable: true, children: [], @@ -600,7 +600,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { - ...NormNodeDefaults, + ...JSON.parse(JSON.stringify(NormNodeDefaults)), label: 'Test Norm', droppable: true, norm: '', @@ -652,7 +652,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { - ...NormNodeDefaults, + ...JSON.parse(JSON.stringify(NormNodeDefaults)), label: 'Test Norm', droppable: true, norm: '', @@ -701,7 +701,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { - ...NormNodeDefaults, + ...JSON.parse(JSON.stringify(NormNodeDefaults)), label: 'Norm 1', droppable: true, norm: 'Original norm 1', @@ -714,7 +714,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 100, y: 0 }, data: { - ...NormNodeDefaults, + ...JSON.parse(JSON.stringify(NormNodeDefaults)), label: 'Norm 2', droppable: true, norm: 'Original norm 2', @@ -827,7 +827,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { - ...NormNodeDefaults, + ...JSON.parse(JSON.stringify(NormNodeDefaults)), label: 'Test Norm', droppable: true, norm: 'haa haa fuyaaah - link', @@ -840,7 +840,7 @@ describe('NormNode', () => { type: 'basic_belief', position: {x:100, y:100}, data: { - ...BasicBeliefNodeDefaults + ...JSON.parse(JSON.stringify(BasicBeliefNodeDefaults)) } }; @@ -889,6 +889,8 @@ describe('NormNode', () => { await waitFor(() => { expect(screen.getByTestId('norm-condition-information')).toBeInTheDocument(); }); + + }); it('should update the data when adding beliefs', async () => { @@ -898,7 +900,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { - ...NormNodeDefaults, + ...JSON.parse(JSON.stringify(NormNodeDefaults)), label: 'Test Norm', droppable: true, norm: 'haa haa fuyaaah - link', @@ -911,7 +913,7 @@ describe('NormNode', () => { type: 'basic_belief', position: {x:100, y:100}, data: { - ...BasicBeliefNodeDefaults + ...JSON.parse(JSON.stringify(BasicBeliefNodeDefaults)) } }; @@ -920,7 +922,7 @@ describe('NormNode', () => { type: 'basic_belief', position: {x:300, y:300}, data: { - ...BasicBeliefNodeDefaults + ...JSON.parse(JSON.stringify(BasicBeliefNodeDefaults)) } }; @@ -947,7 +949,7 @@ describe('NormNode', () => { const state = useFlowStore.getState(); const updatedNorm = state.nodes.find(n => n.id === 'norm-1'); console.log(updatedNorm?.data.conditions); - expect(updatedNorm?.data.conditions).toEqual(["basic_belief-1", "basic_belief-1", "basic_belief-2"]); + expect(updatedNorm?.data.conditions).toEqual(["basic_belief-1", "basic_belief-2"]); }); diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx index 4491388..48a3fb9 100644 --- a/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx @@ -124,7 +124,7 @@ describe('Universal Nodes', () => { // Simulate connection useFlowStore.getState().onConnect({ - source: 'source-1', + source: 'source-1', target: 'target-1', sourceHandle: null, targetHandle: null, -- 2.49.1 From 0b29cb585864ff41ead575f8c2219ea42cf6ca05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Tue, 16 Dec 2025 15:41:30 +0100 Subject: [PATCH 148/184] chore: remove console log ref: N25B-392 --- .../visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx index f003547..c762fff 100644 --- a/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx @@ -945,14 +945,9 @@ describe('NormNode', () => { targetHandle: null, }); - const state = useFlowStore.getState(); const updatedNorm = state.nodes.find(n => n.id === 'norm-1'); - console.log(updatedNorm?.data.conditions); expect(updatedNorm?.data.conditions).toEqual(["basic_belief-1", "basic_belief-2"]); }); - - - }); }); \ No newline at end of file -- 2.49.1 From c1ef924be1e13b1c6da75b15394e04e0f1ef9375 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Tue, 16 Dec 2025 18:21:19 +0100 Subject: [PATCH 149/184] feat: create dialog for plan creation in triggers, make sure to bind the correct things in triggers. Change the norms to take one condition, rather than a list. yes, tests are probably still broken. ref: N25B-412 --- src/pages/VisProgPage/VisProg.module.css | 64 ++++ .../components/Plan.default.ts | 7 + .../visualProgrammingUI/components/Plan.tsx | 25 ++ .../visualProgrammingUI/nodes/NormNode.tsx | 38 +-- .../visualProgrammingUI/nodes/TriggerNode.tsx | 280 +++++++++++++++++- 5 files changed, 377 insertions(+), 37 deletions(-) create mode 100644 src/pages/VisProgPage/visualProgrammingUI/components/Plan.default.ts create mode 100644 src/pages/VisProgPage/visualProgrammingUI/components/Plan.tsx diff --git a/src/pages/VisProgPage/VisProg.module.css b/src/pages/VisProgPage/VisProg.module.css index 14619c5..cd61b5e 100644 --- a/src/pages/VisProgPage/VisProg.module.css +++ b/src/pages/VisProgPage/VisProg.module.css @@ -138,4 +138,68 @@ border-radius: 5pt; outline: plum solid 2pt; filter: drop-shadow(0 0 0.25rem plum); +} + +.planDialog { + width: 80vw; + max-width: 900px; + padding: 1rem; + border: none; + border-radius: 8px; +} + +.planDialog::backdrop { + background: rgba(0, 0, 0, 0.4); +} + +.planEditor { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; + min-width: 600px; +} + +.planEditorLeft { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.planEditorRight { + display: flex; + flex-direction: column; + gap: 0.5rem; + border-left: 1px solid var(--border-color, #ccc); + padding-left: 1rem; + max-height: 300px; + overflow-y: auto; +} + +.planStep { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; + transition: text-decoration 0.2s; +} + +.planStep:hover { + text-decoration: line-through; +} + +.stepType { + margin-left: auto; + opacity: 0.7; + font-size: 0.85em; +} + +.stepIndex { + opacity: 0.6; +} + + + +.emptySteps { + opacity: 0.5; + font-style: italic; } \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/Plan.default.ts b/src/pages/VisProgPage/visualProgrammingUI/components/Plan.default.ts new file mode 100644 index 0000000..b2ea31b --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/components/Plan.default.ts @@ -0,0 +1,7 @@ +import type { Plan } from "./Plan"; + +export const defaultPlan: Plan = { + name: "Default Plan", + id: "-1", + steps: [], +} \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/Plan.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/Plan.tsx new file mode 100644 index 0000000..a74cbf2 --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/components/Plan.tsx @@ -0,0 +1,25 @@ +export type Plan = { + name: string, + id: string, + steps: PlanElement[], +} + +export type PlanElement = Goal | Action + +export type Goal = { + id: string, + name: string, + plan: Plan, + can_fail: boolean, + type: "goal" +} + +// Actions +export type Action = SpeechAction | GestureAction | LLMAction +export type SpeechAction = { name: string, id: string, text: string, type:"speech" } +export type GestureAction = { name: string, id: string, gesture: string, type:"gesture" } +export type LLMAction = { name: string, id: string, goal: string, type:"llm" } + +export type ActionTypes = "speech" | "gesture" | "llm"; + + diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx index 4e94834..ef65215 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx @@ -20,7 +20,7 @@ import { BasicBeliefReduce } from './BasicBeliefNode'; export type NormNodeData = { label: string; droppable: boolean; - conditions: string[]; // List of (basic) belief nodes' ids. + condition?: string; // id of this node's belief. norm: string; hasReduce: boolean; critical: boolean; @@ -70,13 +70,12 @@ export default function NormNode(props: NodeProps) { />
    - {data.conditions.length > 0 && (
    - + {data.condition && (
    +
    )} - - - + +
    ; }; @@ -90,11 +89,6 @@ export default function NormNode(props: NodeProps) { export function NormReduce(node: Node, nodes: Node[]) { const data = node.data as NormNodeData; - // conditions nodes - make sure to check for empty arrays - let conditionNodes: Node[] = []; - if (data.conditions) - conditionNodes = nodes.filter((node) => data.conditions.includes(node.id)); - // Build the result object const result: Record = { id: node.id, @@ -103,12 +97,14 @@ export function NormReduce(node: Node, nodes: Node[]) { critical: data.critical, }; - // Go over our conditionNodes. They should either be Basic (OR TODO: Inferred) - const reducer = BasicBeliefReduce; - result["basic_beliefs"] = conditionNodes.map((condition) => reducer(condition, nodes)) + if (data.condition) { + const reducer = BasicBeliefReduce; // TODO: also add inferred. + const conditionNode = nodes.find((node) => node.id === data.condition); + // In case something went wrong, and our condition doesn't actually exist; + if (conditionNode == undefined) return result; + result["belief"] = reducer(conditionNode, nodes) + } - // When the Inferred is being implemented, you should follow the same kind of structure that PhaseNode has, - // dividing the conditions into basic and inferred, then calling the correct reducer on them. return result } @@ -119,9 +115,9 @@ export function NormReduce(node: Node, nodes: Node[]) { */ export function NormConnectionTarget(_thisNode: Node, _sourceNodeId: string) { const data = _thisNode.data as NormNodeData; - // If we got a belief connected, this is a condition for the norm. + // If we got a belief connected, this is the condition for the norm. if ((useFlowStore.getState().nodes.find((node) => node.id === _sourceNodeId && node.type === 'basic_belief' /* TODO: Add the option for an inferred belief */))) { - data.conditions.push(_sourceNodeId); + data.condition = _sourceNodeId; } } @@ -141,10 +137,8 @@ export function NormConnectionSource(_thisNode: Node, _targetNodeId: string) { */ export function NormDisconnectionTarget(_thisNode: Node, _sourceNodeId: string) { const data = _thisNode.data as NormNodeData; - // If we got a belief connected, this is a condition for the norm. - if ((useFlowStore.getState().nodes.find((node) => node.id === _sourceNodeId && node.type === 'basic_belief' /* TODO: Add the option for an inferred belief */))) { - data.conditions = data.conditions.filter(id => id != _sourceNodeId); - } + // remove if the target of disconnection was our condition + if (_sourceNodeId == data.condition) data.condition = undefined } /** diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx index cad7015..6e3d940 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx @@ -9,9 +9,11 @@ import { import { Toolbar } from '../components/NodeComponents'; import styles from '../../VisProg.module.css'; import useFlowStore from '../VisProgStores'; -import { useState } from 'react'; +import { useState, useRef } from 'react'; import { RealtimeTextField, TextField } from '../../../../components/TextField'; import duplicateIndices from '../../../../utils/duplicateIndices'; +import type { Action, ActionTypes, Plan } from '../components/Plan'; +import { defaultPlan } from '../components/Plan.default'; /** * The default data structure for a Trigger node @@ -28,8 +30,8 @@ import duplicateIndices from '../../../../utils/duplicateIndices'; export type TriggerNodeData = { label: string; droppable: boolean; - triggerType: "keywords" | string; - triggers: Keyword[] | never; + condition?: string; // id of the belief + plan?: Plan; hasReduce: boolean; }; @@ -55,25 +57,265 @@ export function TriggerNodeCanConnect(connection: Connection | Edge): boolean { export default function TriggerNode(props: NodeProps) { const data = props.data; const {updateNodeData} = useFlowStore(); + const dialogRef = useRef(null); + const [draftPlan, setDraftPlan] = useState(null); + + // Helpers for inside plan creation + const [newActionType, setNewActionType] = useState("speech"); + const [newActionName, setNewActionName] = useState(""); + const [newActionValue, setNewActionValue] = useState(""); + + + // Create a new Plan + const openCreateDialog = () => { + setDraftPlan(JSON.parse(JSON.stringify(defaultPlan))); + dialogRef.current?.showModal(); + }; + + // Edit our current plan + const openEditDialog = () => { + if (!data.plan) return; + setDraftPlan(JSON.parse(JSON.stringify(data.plan))); + dialogRef.current?.showModal(); + }; + + // Close the creating/editing of our plan + const closeDialog = () => { + dialogRef.current?.close(); + }; + + + // Define the function for creating actions + const buildAction = (): Action => { + const id = crypto.randomUUID(); + + switch (newActionType) { + case "speech": + return { + id, + name: newActionName, + text: newActionValue, + type: "speech" + }; + case "gesture": + return { + id, + name: newActionName, + gesture: newActionValue, + type: "gesture" + }; + case "llm": + return { + id, + name: newActionName, + goal: newActionValue, + type: "llm" + }; + } + }; - const setKeywords = (keywords: Keyword[]) => { - updateNodeData(props.id, {...data, triggers: keywords}); - } return <>
    - {data.triggerType === "emotion" && ( -
    Emotion?
    - )} - {data.triggerType === "keywords" && ( - - )} +
    Triggers when the condition is met.
    +
    Condition/ Belief is currently {data.condition ? "" : "not"} set. {data.condition ? "🟢" : "🔴"}
    +
    Plan{data.plan ? (": " + data.plan.name) : ""} is currently {data.plan ? "" : "not"} set. {data.plan ? "🟢" : "🔴"}
    + + + {/* We don't have a plan yet, show our create plan button */} + {!data.plan && ( + + )} + + {/* We have a plan, show our edit plan button */} + {data.plan && ( + + )}
    + + {/* Define how our dialog should work */} + e.preventDefault()} + > +
    +

    {draftPlan?.id === data.plan?.id ? "Edit Plan" : "Create Plan"}

    + + {/*Text field to edit the name of our draft plan*/} +
    + {/* LEFT: plan info + add action */} +
    + {/* Plan name */} + {draftPlan && ( +
    + + + setDraftPlan({ + ...draftPlan, + name, + }) + } + /> +
    + )} + + {/* Add action UI */} + {draftPlan && ( +
    +

    Add Action

    + + + + + + + + +
    + )} +
    + + {/* RIGHT: steps list */} +
    +

    Steps

    + + {draftPlan && draftPlan.steps.length === 0 && ( +
    + No steps yet +
    + )} + + {draftPlan?.steps.map((step, index) => ( +
    { + if (!draftPlan) return; + setDraftPlan({ + ...draftPlan, + steps: draftPlan.steps.filter((s) => s.id !== step.id), + }); + }} + > + {index + 1}. + {step.name} + {step.type} +
    + ))} +
    +
    + +
    + {/*Button to close the plan editor.*/} + + + {/*Button to save the draftPlan to the plan in the Node.*/} + + + {/*Button to reset the plan*/} + +
    +
    +
    ; } @@ -108,6 +350,11 @@ export function TriggerReduce(node: Node, _nodes: Node[]) { */ export function TriggerConnectionTarget(_thisNode: Node, _sourceNodeId: string) { // no additional connection logic exists yet + const data = _thisNode.data as TriggerNodeData; + // If we got a belief connected, this is the condition for the norm. + if ((useFlowStore.getState().nodes.find((node) => node.id === _sourceNodeId && node.type === 'basic_belief' /* TODO: Add the option for an inferred belief */))) { + data.condition = _sourceNodeId; + } } /** @@ -126,6 +373,9 @@ export function TriggerConnectionSource(_thisNode: Node, _targetNodeId: string) */ export function TriggerDisconnectionTarget(_thisNode: Node, _sourceNodeId: string) { // no additional connection logic exists yet + const data = _thisNode.data as TriggerNodeData; + // remove if the target of disconnection was our condition + if (_sourceNodeId == data.condition) data.condition = undefined } /** -- 2.49.1 From 444e8b0289154f5c3feb4573cd2c2cf3a6592541 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Wed, 17 Dec 2025 15:51:50 +0100 Subject: [PATCH 150/184] feat: fix a lot of small changes to match cb, add functionality for all plans, add tests for the new plan editor. even more i dont really know anymore. ref: N25B-412 --- src/components/TextField.module.css | 2 + src/components/TextField.tsx | 2 +- src/pages/VisProgPage/VisProg.module.css | 16 +- .../visualProgrammingUI/components/Plan.tsx | 51 +- .../components/PlanEditor.tsx | 237 +++++++++ .../nodes/BasicBeliefNode.tsx | 24 +- .../nodes/GoalNode.default.ts | 3 +- .../visualProgrammingUI/nodes/GoalNode.tsx | 44 +- .../nodes/NormNode.default.ts | 1 - .../visualProgrammingUI/nodes/PhaseNode.tsx | 2 +- .../nodes/TriggerNode.default.ts | 2 - .../visualProgrammingUI/nodes/TriggerNode.tsx | 385 +-------------- .../components/PlanEditor.test.tsx | 450 ++++++++++++++++++ .../nodes/NormNode.test.tsx | 23 +- .../nodes/TriggerNode.test.tsx | 201 ++------ .../nodes/UniversalNodes.test.tsx | 2 +- 16 files changed, 884 insertions(+), 561 deletions(-) create mode 100644 src/pages/VisProgPage/visualProgrammingUI/components/PlanEditor.tsx create mode 100644 test/pages/visProgPage/visualProgrammingUI/components/PlanEditor.test.tsx diff --git a/src/components/TextField.module.css b/src/components/TextField.module.css index de66531..1f40d85 100644 --- a/src/components/TextField.module.css +++ b/src/components/TextField.module.css @@ -2,6 +2,8 @@ border: 1px solid transparent; border-radius: 5pt; padding: 4px 8px; + max-width: 50vw; + min-width: 10vw; outline: none; background-color: canvas; transition: border-color 0.2s, box-shadow 0.2s; diff --git a/src/components/TextField.tsx b/src/components/TextField.tsx index 6dbc47b..6395e18 100644 --- a/src/components/TextField.tsx +++ b/src/components/TextField.tsx @@ -63,7 +63,7 @@ export function RealtimeTextField({ readOnly={readOnly} id={id} // ReactFlow uses the "drag" / "nodrag" classes to enable / disable dragging of nodes - className={`${readOnly ? "drag" : "nodrag"} ${styles.textField} ${invalid ? styles.invalid : ""} ${className}`} + className={`${readOnly ? "drag" : "nodrag"} flex-1 ${styles.textField} ${invalid ? styles.invalid : ""} ${className}`} aria-label={ariaLabel} />; } diff --git a/src/pages/VisProgPage/VisProg.module.css b/src/pages/VisProgPage/VisProg.module.css index cd61b5e..7731e42 100644 --- a/src/pages/VisProgPage/VisProg.module.css +++ b/src/pages/VisProgPage/VisProg.module.css @@ -183,23 +183,33 @@ transition: text-decoration 0.2s; } + .planStep:hover { text-decoration: line-through; } .stepType { - margin-left: auto; opacity: 0.7; font-size: 0.85em; } + .stepIndex { opacity: 0.6; } - - .emptySteps { opacity: 0.5; font-style: italic; +} + +.stepSuggestion { + opacity: 0.5; + font-style: italic; +} + +.planNoIterate { + opacity: 0.5; + font-style: italic; + text-decoration: line-through; } \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/Plan.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/Plan.tsx index a74cbf2..4955d86 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/Plan.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/Plan.tsx @@ -16,10 +16,55 @@ export type Goal = { // Actions export type Action = SpeechAction | GestureAction | LLMAction -export type SpeechAction = { name: string, id: string, text: string, type:"speech" } -export type GestureAction = { name: string, id: string, gesture: string, type:"gesture" } -export type LLMAction = { name: string, id: string, goal: string, type:"llm" } +export type SpeechAction = { id: string, text: string, type:"speech" } +export type GestureAction = { id: string, gesture: string, type:"gesture" } +export type LLMAction = { id: string, goal: string, type:"llm" } export type ActionTypes = "speech" | "gesture" | "llm"; +// Extract the wanted information from a plan within the reducing of nodes +export function PlanReduce(plan?: Plan) { + if (!plan) return "" + return { + name: plan.name, + id: plan.id, + steps: plan.steps, + } +} + +/** + * Finds out whether the plan can iterate multiple times, or always stops after one action. + * This comes down to checking if the plan only has speech/ gesture actions, or others as well. + * @param plan: the plan to check + * @returns: a boolean + */ +export function DoesPlanIterate(plan?: Plan) : boolean { + // TODO: should recursively check plans that have goals (and thus more plans) in them. + if (!plan) return false + return plan.steps.filter((step) => step.type == "llm").length > 0; +} + +/** + * Returns the value of the action. + * Since typescript can't polymorphicly access the value field, + * we need to switch over the types and return the correct field. + * @param action: action to retrieve the value from + * @returns string | undefined + */ +export function GetActionValue(action: Action) { + let returnAction; + switch (action.type) { + case "gesture": + returnAction = action as GestureAction + return returnAction.gesture; + case "speech": + returnAction = action as SpeechAction + return returnAction.text; + case "llm": + returnAction = action as LLMAction + return returnAction.goal; + default: + break; + } +} \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/PlanEditor.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/PlanEditor.tsx new file mode 100644 index 0000000..af05310 --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/components/PlanEditor.tsx @@ -0,0 +1,237 @@ +import { useRef, useState } from "react"; +import styles from '../../VisProg.module.css'; +import { GetActionValue, type Action, type ActionTypes, type Plan } from "../components/Plan"; +import { defaultPlan } from "../components/Plan.default"; +import { TextField } from "../../../../components/TextField"; + +type PlanEditorDialogProps = { + plan?: Plan; + onSave: (plan: Plan | undefined) => void; + description? : string; +}; + +/** + * Adds an element to a React.JSX.Element that allows for the creation and editing of plans. + * Renders a dialog in the current screen with buttons and text fields for names, actions and other configurability. + * @param param0: Takes in a current plan, which can be undefined and a function which is called on saving with the potential plan. + * @returns: JSX.Element + * @example + * ``` + * // Within a Node's default JSX Element function + * { + * updateNodeData(props.id, { + * ...data, + * plan, + * }); + * }} + * /> + * ``` + */ +export default function PlanEditorDialog({ + plan, + onSave, + description, +}: PlanEditorDialogProps) { + // UseStates and references + const dialogRef = useRef(null); + const [draftPlan, setDraftPlan] = useState(null); + const [newActionType, setNewActionType] = useState("speech"); + const [newActionValue, setNewActionValue] = useState(""); + + //Button Actions + const openCreate = () => { + setDraftPlan({...structuredClone(defaultPlan), id: crypto.randomUUID()}); + dialogRef.current?.showModal(); + }; + + const openCreateWithDescription = () => { + setDraftPlan({...structuredClone(defaultPlan), id: crypto.randomUUID(), name: description!}); + setNewActionType("llm") + setNewActionValue(description!) + dialogRef.current?.showModal(); + } + + + + const openEdit = () => { + if (!plan) return; + setDraftPlan(structuredClone(plan)); + dialogRef.current?.showModal(); + }; + + const close = () => { + dialogRef.current?.close(); + setDraftPlan(null); + }; + + const buildAction = (): Action => { + const id = crypto.randomUUID(); + switch (newActionType) { + case "speech": + return { id, text: newActionValue, type: "speech" }; + case "gesture": + return { id, gesture: newActionValue, type: "gesture" }; + case "llm": + return { id, goal: newActionValue, type: "llm" }; + } + }; + + return (<> + {/* Create and edit buttons */} + {!plan && ( + + )} + {plan && ( + + )} + + {/* Start of dialog (plan editor) */} + e.preventDefault()} + > +
    +

    {draftPlan?.id === plan?.id ? "Edit Plan" : "Create Plan"}

    + + {/* Plan name text field */} + {draftPlan && ( + + setDraftPlan({ ...draftPlan, name })} + placeholder="Plan name" + data-testid="name_text_field"/> + )} + + {/* Entire "bottom" part (adder and steps) without cancel, confirm and reset */} + {draftPlan && (
    +
    + {/* Left Side (Action Adder) */} +

    Add Action

    + {(!plan && description && draftPlan.steps.length === 0) && (
    + + +
    )} + + + {/* Action value editor */} + + + {/* Adding steps */} + +
    + + {/* Right Side (Steps shown) */} +
    +

    Steps

    + + {/* Show if there are no steps yet */} + {draftPlan.steps.length === 0 && ( +
    + No steps yet +
    + )} + + + {/* Map over all steps, create a div for them that deletes them + if clicked on and add the index, name and type. as spans */} + {draftPlan.steps.map((step, index) => ( +
    { + setDraftPlan({ + ...draftPlan, + steps: draftPlan.steps.filter((s) => s.id !== step.id),}); + }}> + {index + 1}. + {step.type}: + { + step.type == "goal" ? ""/* TODO: Add support for goals */ + : GetActionValue(step)} + +
    + ))} +
    +
    + )} {/* End Action Editor and steps shower */} + + {/* Buttons */} +
    + {/* Close button */} + + + {/* Confirm/ Create button */} + + + {/* Reset button */} + +
    + +
    + + ); +} diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx index a8f7ceb..9fa4017 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx @@ -181,11 +181,27 @@ export default function BasicBeliefNode(props: NodeProps) { */ export function BasicBeliefReduce(node: Node, _nodes: Node[]) { const data = node.data as BasicBeliefNodeData; - return { - id: node.id, - type: data.belief.type, - value: data.belief.value + const result: Record = { + id: node.id, + }; + + switch (data.belief.type) { + case "emotion": + result["emotion"] = data.belief.value; + break; + case "keyword": + result["keyword"] = data.belief.value; + break; + case "object": + result["object"] = data.belief.value; + break; + case "semantic": + result["description"] = data.belief.value; + break; + default: + break; } + return result } /** diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.default.ts b/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.default.ts index fc4d3aa..4cf314c 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.default.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.default.ts @@ -6,7 +6,8 @@ import type { GoalNodeData } from "./GoalNode"; export const GoalNodeDefaults: GoalNodeData = { label: "Goal Node", droppable: true, - description: "The robot will strive towards this goal", + description: "", achieved: false, hasReduce: true, + can_fail: true, }; \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx index 1564969..75b8b99 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx @@ -8,6 +8,8 @@ import { Toolbar } from '../components/NodeComponents'; import styles from '../../VisProg.module.css'; import { TextField } from '../../../../components/TextField'; import useFlowStore from '../VisProgStores'; +import { DoesPlanIterate, PlanReduce, type Plan } from '../components/Plan'; +import PlanEditorDialog from '../components/PlanEditor'; /** * The default data dot a phase node @@ -22,6 +24,8 @@ export type GoalNodeData = { droppable: boolean; achieved: boolean; hasReduce: boolean; + can_fail: boolean; + plan?: Plan; }; export type GoalNode = Node @@ -37,13 +41,14 @@ export default function GoalNode({id, data}: NodeProps) { const text_input_id = `goal_${id}_text_input`; const checkbox_id = `goal_${id}_checkbox`; + const planIterate = DoesPlanIterate(data.plan); const setDescription = (value: string) => { updateNodeData(id, {...data, description: value}); } - const setAchieved = (value: boolean) => { - updateNodeData(id, {...data, achieved: value}); + const setFailable = (value: boolean) => { + updateNodeData(id, {...data, can_fail: value}); } return <> @@ -57,14 +62,34 @@ export default function GoalNode({id, data}: NodeProps) { setValue={(val) => setDescription(val)} placeholder={"To ..."} /> +
    -
    - +
    + +
    + {data.plan && (
    + {planIterate ? "" : } + setAchieved(e.target.checked)} + disabled={!planIterate} + checked={!planIterate || data.can_fail} + onChange={(e) => planIterate ? setFailable(e.target.checked) : undefined} + /> +
    +)} + +
    + { + updateNodeData(id, { + ...data, + plan, + }); + }} + description={data.description} />
    @@ -80,11 +105,12 @@ export default function GoalNode({id, data}: NodeProps) { */ export function GoalReduce(node: Node, _nodes: Node[]) { const data = node.data as GoalNodeData; - return { + return { id: node.id, - label: data.label, + name: data.label, description: data.description, - achieved: data.achieved, + can_fail: data.can_fail, + plan: data.plan ? PlanReduce(data.plan) : "", } } diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.default.ts b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.default.ts index 8df25cc..4b4a3ed 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.default.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.default.ts @@ -6,7 +6,6 @@ import type { NormNodeData } from "./NormNode"; export const NormNodeDefaults: NormNodeData = { label: "Norm Node", droppable: true, - conditions: [], norm: "", hasReduce: true, critical: false, diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx index 41679f1..d12ad62 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx @@ -86,7 +86,7 @@ export function PhaseReduce(node: Node, nodes: Node[]) { // Build the result object const result: Record = { id: thisnode.id, - label: data.label, + name: data.label, }; nodesInPhase.forEach((type) => { diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.default.ts b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.default.ts index d1daf4a..2a63661 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.default.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.default.ts @@ -6,7 +6,5 @@ import type { TriggerNodeData } from "./TriggerNode"; export const TriggerNodeDefaults: TriggerNodeData = { label: "Trigger Node", droppable: true, - triggers: [], - triggerType: "keywords", hasReduce: true, }; \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx index 6e3d940..1778d32 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx @@ -9,11 +9,9 @@ import { import { Toolbar } from '../components/NodeComponents'; import styles from '../../VisProg.module.css'; import useFlowStore from '../VisProgStores'; -import { useState, useRef } from 'react'; -import { RealtimeTextField, TextField } from '../../../../components/TextField'; -import duplicateIndices from '../../../../utils/duplicateIndices'; -import type { Action, ActionTypes, Plan } from '../components/Plan'; -import { defaultPlan } from '../components/Plan.default'; +import { PlanReduce, type Plan } from '../components/Plan'; +import PlanEditorDialog from '../components/PlanEditor'; +import { BasicBeliefReduce } from './BasicBeliefNode'; /** * The default data structure for a Trigger node @@ -23,8 +21,6 @@ import { defaultPlan } from '../components/Plan.default'; * * @property label: the display label of this Trigger node. * @property droppable: Whether this node can be dropped from the toolbar (default: true). - * @property triggerType - The type of trigger ("keywords" or a custom string). - * @property triggers - The list of keyword triggers (if applicable). * @property hasReduce - Whether this node supports reduction logic. */ export type TriggerNodeData = { @@ -44,6 +40,7 @@ export type TriggerNode = Node * * @param connection - The connection or edge being attempted to connect towards. * @returns `true` if the connection is defined; otherwise, `false`. + * */ export function TriggerNodeCanConnect(connection: Connection | Edge): boolean { return (connection != undefined); @@ -57,64 +54,7 @@ export function TriggerNodeCanConnect(connection: Connection | Edge): boolean { export default function TriggerNode(props: NodeProps) { const data = props.data; const {updateNodeData} = useFlowStore(); - const dialogRef = useRef(null); - const [draftPlan, setDraftPlan] = useState(null); - - // Helpers for inside plan creation - const [newActionType, setNewActionType] = useState("speech"); - const [newActionName, setNewActionName] = useState(""); - const [newActionValue, setNewActionValue] = useState(""); - - - // Create a new Plan - const openCreateDialog = () => { - setDraftPlan(JSON.parse(JSON.stringify(defaultPlan))); - dialogRef.current?.showModal(); - }; - - // Edit our current plan - const openEditDialog = () => { - if (!data.plan) return; - setDraftPlan(JSON.parse(JSON.stringify(data.plan))); - dialogRef.current?.showModal(); - }; - - // Close the creating/editing of our plan - const closeDialog = () => { - dialogRef.current?.close(); - }; - - - // Define the function for creating actions - const buildAction = (): Action => { - const id = crypto.randomUUID(); - - switch (newActionType) { - case "speech": - return { - id, - name: newActionName, - text: newActionValue, - type: "speech" - }; - case "gesture": - return { - id, - name: newActionName, - gesture: newActionValue, - type: "gesture" - }; - case "llm": - return { - id, - name: newActionName, - goal: newActionValue, - type: "llm" - }; - } - }; - - + return <>
    @@ -123,199 +63,16 @@ export default function TriggerNode(props: NodeProps) {
    Plan{data.plan ? (": " + data.plan.name) : ""} is currently {data.plan ? "" : "not"} set. {data.plan ? "🟢" : "🔴"}
    - - {/* We don't have a plan yet, show our create plan button */} - {!data.plan && ( - - )} - - {/* We have a plan, show our edit plan button */} - {data.plan && ( - - )} + { + updateNodeData(props.id, { + ...data, + plan, + }); + }} + />
    - - {/* Define how our dialog should work */} - e.preventDefault()} - > -
    -

    {draftPlan?.id === data.plan?.id ? "Edit Plan" : "Create Plan"}

    - - {/*Text field to edit the name of our draft plan*/} -
    - {/* LEFT: plan info + add action */} -
    - {/* Plan name */} - {draftPlan && ( -
    - - - setDraftPlan({ - ...draftPlan, - name, - }) - } - /> -
    - )} - - {/* Add action UI */} - {draftPlan && ( -
    -

    Add Action

    - - - - - - - - -
    - )} -
    - - {/* RIGHT: steps list */} -
    -

    Steps

    - - {draftPlan && draftPlan.steps.length === 0 && ( -
    - No steps yet -
    - )} - - {draftPlan?.steps.map((step, index) => ( -
    { - if (!draftPlan) return; - setDraftPlan({ - ...draftPlan, - steps: draftPlan.steps.filter((s) => s.id !== step.id), - }); - }} - > - {index + 1}. - {step.name} - {step.type} -
    - ))} -
    -
    - -
    - {/*Button to close the plan editor.*/} - - - {/*Button to save the draftPlan to the plan in the Node.*/} - - - {/*Button to reset the plan*/} - -
    -
    -
    ; } @@ -325,22 +82,16 @@ export default function TriggerNode(props: NodeProps) { * @param _nodes - The list of all nodes in the current flow graph. * @returns A simplified object containing the node label and its list of triggers. */ -export function TriggerReduce(node: Node, _nodes: Node[]) { - const data = node.data; - switch (data.triggerType) { - case "keywords": - return { - id: node.id, - type: "keywords", - label: data.label, - keywords: data.triggers, - }; - default: - return { - ...data, - id: node.id, - }; +export function TriggerReduce(node: Node, nodes: Node[]) { + const data = node.data as TriggerNodeData; + const conditionNode = data.condition ? nodes.find((n)=>n.id===data.condition) : undefined + const conditionData = conditionNode ? BasicBeliefReduce(conditionNode, nodes) : "" + return { + id: node.id, + condition: conditionData, // Make sure we have a condition before reducing, or default to "" + plan: !data.plan ? "" : PlanReduce(data.plan), // Make sure we have a plan when reducing, or default to "" } + } /** @@ -405,92 +156,4 @@ export type KeywordTriggerNodeProps = { } /** Union type for all possible Trigger node configurations. */ -export type TriggerNodeProps = EmotionTriggerNodeProps | KeywordTriggerNodeProps; - -/** - * Renders an input element that allows users to add new keyword triggers. - * - * When the input is committed, the `addKeyword` callback is called with the new keyword. - * - * @param param0 - An object containing the `addKeyword` function. - * @returns A React element(React.JSX.Element) providing an input for adding keywords. - */ -function KeywordAdder({ addKeyword }: { addKeyword: (keyword: string) => void }) { - const [input, setInput] = useState(""); - - const text_input_id = "keyword_adder_input"; - - return
    - - { - if (!input) return; - addKeyword(input); - setInput(""); - }} - placeholder={"..."} - className={"flex-1"} - /> -
    ; -} - -/** - * Displays and manages a list of keyword triggers for a Trigger node. - * Handles adding, editing, and removing keywords, as well as detecting duplicate entries. - * - * @param keywords - The current list of keyword triggers. - * @param setKeywords - A callback to update the keyword list in the parent node. - * @returns A React element(React.JSX.Element) for editing keyword triggers. - */ -function Keywords({ - keywords, - setKeywords, -}: { - keywords: Keyword[]; - setKeywords: (keywords: Keyword[]) => void; -}) { - type Interpolatable = string | number | boolean | bigint | null | undefined; - - const inputElementId = (id: Interpolatable) => `keyword_${id}_input`; - - /** Indices of duplicates in the keyword array. */ - const [duplicates, setDuplicates] = useState([]); - - function replace(id: string, value: string) { - value = value.trim(); - const newKeywords = value === "" - ? keywords.filter((kw) => kw.id != id) - : keywords.map((kw) => kw.id === id ? {...kw, keyword: value} : kw); - setKeywords(newKeywords); - setDuplicates(duplicateIndices(newKeywords.map((kw) => kw.keyword))); - } - - function add(value: string) { - value = value.trim(); - if (value === "") return; - const newKeywords = [...keywords, {id: crypto.randomUUID(), keyword: value}]; - setKeywords(newKeywords); - setDuplicates(duplicateIndices(newKeywords.map((kw) => kw.keyword))); - } - - return <> - Triggers when {keywords.length <= 1 ? "the keyword is" : "all keywords are"} spoken. - {[...keywords].map(({id, keyword}, index) => { - return
    - - replace(id, val)} - placeholder={"..."} - className={"flex-1"} - invalid={duplicates.includes(index)} - /> -
    ; - })} - - ; -} \ No newline at end of file +export type TriggerNodeProps = EmotionTriggerNodeProps | KeywordTriggerNodeProps; \ No newline at end of file diff --git a/test/pages/visProgPage/visualProgrammingUI/components/PlanEditor.test.tsx b/test/pages/visProgPage/visualProgrammingUI/components/PlanEditor.test.tsx new file mode 100644 index 0000000..c7de9a7 --- /dev/null +++ b/test/pages/visProgPage/visualProgrammingUI/components/PlanEditor.test.tsx @@ -0,0 +1,450 @@ +// PlanEditorDialog.test.tsx +import { describe, it, beforeEach, jest } from '@jest/globals'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWithProviders } from '../../../../test-utils/test-utils.tsx'; +import PlanEditorDialog from '../../../../../src/pages/VisProgPage/visualProgrammingUI/components/PlanEditor'; +import type { Plan } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/components/Plan'; +import '@testing-library/jest-dom'; + +// Mock crypto.randomUUID for consistent IDs in tests +const mockUUID = 'test-uuid-123'; + +Object.defineProperty(globalThis, 'crypto', { + value: { + randomUUID: () => mockUUID, + }, + writable: true, +}); + +// Mock structuredClone +(globalThis as any).structuredClone = jest.fn((val) => JSON.parse(JSON.stringify(val))); + +// Mock HTMLDialogElement methods +const mockDialogMethods = { + showModal: jest.fn(), + close: jest.fn(), +}; + +describe('PlanEditorDialog', () => { + let user: ReturnType; + const mockOnSave = jest.fn(); + + beforeEach(() => { + user = userEvent.setup(); + jest.clearAllMocks(); + // Mock dialog element methods + HTMLDialogElement.prototype.showModal = mockDialogMethods.showModal; + HTMLDialogElement.prototype.close = mockDialogMethods.close; + }); + + const defaultPlan: Plan = { + id: 'plan-1', + name: 'Test Plan', + steps: [], + }; + + const planWithSteps: Plan = { + id: 'plan-2', + name: 'Existing Plan', + steps: [ + { id: 'step-1', text: 'Hello world', type: 'speech' as const }, + { id: 'step-2', gesture: 'Wave', type: 'gesture' as const }, + ], + }; + + const renderDialog = (props: Partial> = {}) => { + const defaultProps = { + plan: undefined, + onSave: mockOnSave, + description: undefined, + }; + + return renderWithProviders(); + }; + + describe('Rendering', () => { + it('should show "Create Plan" button when no plan is provided', () => { + renderDialog(); + // The button should be visible + expect(screen.getByRole('button', { name: 'Create Plan' })).toBeInTheDocument(); + // The dialog content should NOT be visible initially + expect(screen.queryByText(/Add Action/i)).not.toBeInTheDocument(); + }); + + it('should show "Edit Plan" button when a plan is provided', () => { + renderDialog({ plan: defaultPlan }); + expect(screen.getByRole('button', { name: 'Edit Plan' })).toBeInTheDocument(); + }); + + it('should not show "Create Plan" button when a plan exists', () => { + renderDialog({ plan: defaultPlan }); + // Query for the button text specifically, not dialog title + expect(screen.queryByRole('button', { name: 'Create Plan' })).not.toBeInTheDocument(); + }); + }); + + describe('Dialog Interactions', () => { + it('should open dialog with "Create Plan" title when creating new plan', async () => { + renderDialog(); + + await user.click(screen.getByRole('button', { name: 'Create Plan' })); + + expect(mockDialogMethods.showModal).toHaveBeenCalled(); + + // One for button, one for dialog. + expect(screen.getAllByText('Create Plan').length).toEqual(2); + }); + + it('should open dialog with "Edit Plan" title when editing existing plan', async () => { + renderDialog({ plan: defaultPlan }); + + await user.click(screen.getByRole('button', { name: 'Edit Plan' })); + + expect(mockDialogMethods.showModal).toHaveBeenCalled(); + // One for button, one for dialog + expect(screen.getAllByText('Edit Plan').length).toEqual(2); + }); + + it('should pre-fill plan name when editing', async () => { + renderDialog({ plan: defaultPlan }); + + await user.click(screen.getByRole('button', { name: 'Edit Plan' })); + + const nameInput = screen.getByPlaceholderText('Plan name') as HTMLInputElement; + expect(nameInput.value).toBe(defaultPlan.name); + }); + + it('should close dialog when cancel button is clicked', async () => { + renderDialog(); + + await user.click(screen.getByRole('button', { name: 'Create Plan' })); + await user.click(screen.getByText('Cancel')); + + expect(mockDialogMethods.close).toHaveBeenCalled(); + }); + }); + + describe('Plan Creation', () => { + it('should create a new plan with default values', async () => { + renderDialog(); + + await user.click(screen.getByRole('button', { name: 'Create Plan' })); + + // One for the button, one for the dialog + expect(screen.getAllByText('Create Plan').length).toEqual(2); + + const nameInput = screen.getByPlaceholderText('Plan name'); + expect(nameInput).toBeInTheDocument(); + }); + + it('should auto-fill with description when provided', async () => { + const description = 'Achieve world peace'; + renderDialog({ description }); + + await user.click(screen.getByRole('button', { name: 'Create Plan' })); + + // Check if plan name is pre-filled with description + const nameInput = screen.getByPlaceholderText('Plan name') as HTMLInputElement; + expect(nameInput.value).toBe(description); + + // Check if action type is set to LLM + const actionTypeSelect = screen.getByLabelText(/Action Type/i) as HTMLSelectElement; + expect(actionTypeSelect.value).toBe('llm'); + + // Check if suggestion text is shown + expect(screen.getByText('Filled in as a suggestion!')).toBeInTheDocument(); + expect(screen.getByText('Feel free to change!')).toBeInTheDocument(); + }); + + it('should allow changing plan name', async () => { + renderDialog(); + + await user.click(screen.getByRole('button', { name: 'Create Plan' })); + + const nameInput = screen.getByPlaceholderText('Plan name') as HTMLInputElement; + const newName = 'My Custom Plan'; + + // Instead of clear(), select all text and type new value + await user.click(nameInput); + await user.keyboard('{Control>}a{/Control}'); // Select all (Ctrl+A) + await user.keyboard(newName); + + expect(nameInput.value).toBe(newName); + }); + }); + + describe('Action Management', () => { + it('should add a speech action to the plan', async () => { + renderDialog({ plan: defaultPlan }); + + await user.click(screen.getByRole('button', { name: 'Edit Plan' })); + + const actionTypeSelect = screen.getByLabelText(/Action Type/i); + const actionValueInput = screen.getByPlaceholderText("Speech text") + const addButton = screen.getByText('Add Step'); + + // Set up a speech action + await user.selectOptions(actionTypeSelect, 'speech'); + await user.type(actionValueInput, 'Hello there!'); + + await user.click(addButton); + + // Check if step was added + expect(screen.getByText('speech:')).toBeInTheDocument(); + expect(screen.getByText('Hello there!')).toBeInTheDocument(); + }); + + it('should add a gesture action to the plan', async () => { + renderDialog({ plan: defaultPlan }); + + await user.click(screen.getByRole('button', { name: 'Edit Plan' })); + + const actionTypeSelect = screen.getByLabelText(/Action Type/i); + const addButton = screen.getByText('Add Step'); + + // Set up a gesture action + await user.selectOptions(actionTypeSelect, 'gesture'); + + // Find the input field after type change + const gestureInput = screen.getByPlaceholderText(/Gesture name|text/i); + await user.type(gestureInput, 'Wave hand'); + + await user.click(addButton); + + // Check if step was added + expect(screen.getByText('gesture:')).toBeInTheDocument(); + expect(screen.getByText('Wave hand')).toBeInTheDocument(); + }); + + it('should add an LLM action to the plan', async () => { + renderDialog({ plan: defaultPlan }); + + await user.click(screen.getByRole('button', { name: 'Edit Plan' })); + + const actionTypeSelect = screen.getByLabelText(/Action Type/i); + const addButton = screen.getByText('Add Step'); + + // Set up an LLM action + await user.selectOptions(actionTypeSelect, 'llm'); + + // Find the input field after type change + const llmInput = screen.getByPlaceholderText(/LLM goal|text/i); + await user.type(llmInput, 'Generate a story'); + + await user.click(addButton); + + // Check if step was added + expect(screen.getByText('llm:')).toBeInTheDocument(); + expect(screen.getByText('Generate a story')).toBeInTheDocument(); + }); + + it('should disable "Add Step" button when action value is empty', async () => { + renderDialog({ plan: defaultPlan }); + + await user.click(screen.getByRole('button', { name: 'Edit Plan' })); + + const addButton = screen.getByText('Add Step'); + expect(addButton).toBeDisabled(); + }); + + it('should reset action form after adding a step', async () => { + renderDialog({ plan: defaultPlan }); + + await user.click(screen.getByRole('button', { name: 'Edit Plan' })); + + + const actionValueInput = screen.getByPlaceholderText("Speech text") + const addButton = screen.getByText('Add Step'); + + await user.type(actionValueInput, 'Test speech'); + await user.click(addButton); + + // Action value should be cleared + expect(actionValueInput).toHaveValue(''); + // Action type should be reset to speech (default) + const actionTypeSelect = screen.getByLabelText(/Action Type/i) as HTMLSelectElement; + expect(actionTypeSelect.value).toBe('speech'); + }); + }); + + describe('Step Management', () => { + it('should show existing steps when editing a plan', async () => { + renderDialog({ plan: planWithSteps }); + + await user.click(screen.getByRole('button', { name: 'Edit Plan' })); + + // Check if existing steps are shown + expect(screen.getByText('speech:')).toBeInTheDocument(); + expect(screen.getByText('Hello world')).toBeInTheDocument(); + expect(screen.getByText('gesture:')).toBeInTheDocument(); + expect(screen.getByText('Wave')).toBeInTheDocument(); + }); + + it('should show "No steps yet" message when plan has no steps', async () => { + renderDialog({ plan: defaultPlan }); + + await user.click(screen.getByRole('button', { name: 'Edit Plan' })); + + expect(screen.getByText('No steps yet')).toBeInTheDocument(); + }); + + it('should remove a step when clicked', async () => { + renderDialog({ plan: planWithSteps }); + + await user.click(screen.getByRole('button', { name: 'Edit Plan' })); + + // Initially have 2 steps + expect(screen.getByText('speech:')).toBeInTheDocument(); + expect(screen.getByText('gesture:')).toBeInTheDocument(); + + // Click on the first step to remove it + await user.click(screen.getByText('Hello world')); + + // First step should be removed + expect(screen.queryByText('Hello world')).not.toBeInTheDocument(); + // Second step should still exist + expect(screen.getByText('Wave')).toBeInTheDocument(); + }); + }); + + describe('Save Functionality', () => { + it('should call onSave with new plan when creating', async () => { + renderDialog(); + + await user.click(screen.getByRole('button', { name: 'Create Plan' })); + + // Set plan name + const nameInput = screen.getByPlaceholderText('Plan name') as HTMLInputElement; + await user.click(nameInput); + await user.keyboard('{Control>}a{/Control}'); + await user.keyboard('My New Plan'); + + // Add a step + const actionValueInput = screen.getByPlaceholderText(/text/i); + await user.type(actionValueInput, 'First step'); + await user.click(screen.getByText('Add Step')); + + // Save the plan + await user.click(screen.getByText('Create')); + + expect(mockOnSave).toHaveBeenCalledWith({ + id: mockUUID, + name: 'My New Plan', + steps: [ + { + id: mockUUID, + text: 'First step', + type: 'speech', + }, + ], + }); + expect(mockDialogMethods.close).toHaveBeenCalled(); + }); + + it('should call onSave with updated plan when editing', async () => { + renderDialog({ plan: defaultPlan }); + + await user.click(screen.getByRole('button', { name: 'Edit Plan' })); + + // Change plan name + const nameInput = screen.getByPlaceholderText('Plan name') as HTMLInputElement; + await user.click(nameInput); + await user.keyboard('{Control>}a{/Control}'); + await user.keyboard('Updated Plan Name'); + + // Add a step + const actionValueInput = screen.getByPlaceholderText(/text/i); + await user.type(actionValueInput, 'New speech action'); + await user.click(screen.getByText('Add Step')); + + // Save the plan + await user.click(screen.getByText('Confirm')); + + expect(mockOnSave).toHaveBeenCalledWith({ + id: defaultPlan.id, + name: 'Updated Plan Name', + steps: [ + { + id: mockUUID, + text: 'New speech action', + type: 'speech', + }, + ], + }); + expect(mockDialogMethods.close).toHaveBeenCalled(); + }); + + it('should call onSave with undefined when reset button is clicked', async () => { + renderDialog({ plan: defaultPlan }); + + await user.click(screen.getByRole('button', { name: 'Edit Plan' })); + await user.click(screen.getByText('Reset')); + + expect(mockOnSave).toHaveBeenCalledWith(undefined); + expect(mockDialogMethods.close).toHaveBeenCalled(); + }); + + it('should disable save button when no draft plan exists', async () => { + renderDialog(); + + await user.click(screen.getByRole('button', { name: 'Create Plan' })); + + // The save button should be enabled since draftPlan exists after clicking Create Plan + const saveButton = screen.getByText('Create'); + expect(saveButton).not.toBeDisabled(); + }); + }); + + describe('Step Indexing', () => { + it('should show correct step numbers', async () => { + renderDialog({ plan: defaultPlan }); + + await user.click(screen.getByRole('button', { name: 'Edit Plan' })); + + // Add multiple steps + const actionValueInput = screen.getByPlaceholderText(/text/i); + const addButton = screen.getByText('Add Step'); + + await user.type(actionValueInput, 'First'); + await user.click(addButton); + + await user.type(actionValueInput, 'Second'); + await user.click(addButton); + + await user.type(actionValueInput, 'Third'); + await user.click(addButton); + + // Check step numbers + expect(screen.getByText('1.')).toBeInTheDocument(); + expect(screen.getByText('2.')).toBeInTheDocument(); + expect(screen.getByText('3.')).toBeInTheDocument(); + }); + }); + + describe('Action Type Switching', () => { + it('should update placeholder text when action type changes', async () => { + renderDialog(); + + await user.click(screen.getByRole('button', { name: 'Create Plan' })); + + const actionTypeSelect = screen.getByLabelText(/Action Type/i); + + // Check speech placeholder + await user.selectOptions(actionTypeSelect, 'speech'); + // The placeholder might be set dynamically, so we need to check the input + const speechInput = screen.getByPlaceholderText(/text/i); + expect(speechInput).toBeInTheDocument(); + + // Check gesture placeholder + await user.selectOptions(actionTypeSelect, 'gesture'); + const gestureInput = screen.getByPlaceholderText(/Gesture|text/i); + expect(gestureInput).toBeInTheDocument(); + + // Check LLM placeholder + await user.selectOptions(actionTypeSelect, 'llm'); + const llmInput = screen.getByPlaceholderText(/LLM|text/i); + expect(llmInput).toBeInTheDocument(); + }); + }); +}); \ No newline at end of file diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx index c762fff..29e6a0c 100644 --- a/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx @@ -425,7 +425,6 @@ describe('NormNode', () => { label: 'Safety Norm', norm: 'Never harm humans', critical: false, - basic_beliefs: [], }); }); @@ -917,17 +916,8 @@ describe('NormNode', () => { } }; - const mockBelief2: Node = { - id: 'basic_belief-2', - type: 'basic_belief', - position: {x:300, y:300}, - data: { - ...JSON.parse(JSON.stringify(BasicBeliefNodeDefaults)) - } - }; - useFlowStore.setState({ - nodes: [mockNode, mockBelief1, mockBelief2], + nodes: [mockNode, mockBelief1], edges: [], }); @@ -938,16 +928,11 @@ describe('NormNode', () => { sourceHandle: null, targetHandle: null, }); - useFlowStore.getState().onConnect({ - source: 'basic_belief-2', - target: 'norm-1', - sourceHandle: null, - targetHandle: null, - }); - + + const state = useFlowStore.getState(); const updatedNorm = state.nodes.find(n => n.id === 'norm-1'); - expect(updatedNorm?.data.conditions).toEqual(["basic_belief-1", "basic_belief-2"]); + expect(updatedNorm?.data.condition).toEqual("basic_belief-1"); }); }); }); \ No newline at end of file diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/TriggerNode.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/TriggerNode.test.tsx index e3c40e0..6313258 100644 --- a/test/pages/visProgPage/visualProgrammingUI/nodes/TriggerNode.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/TriggerNode.test.tsx @@ -1,6 +1,5 @@ import { describe, it, beforeEach } from '@jest/globals'; -import { screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { screen } from '@testing-library/react'; import { renderWithProviders } from '../../../../test-utils/test-utils.tsx'; import TriggerNode, { TriggerReduce, @@ -11,12 +10,15 @@ import TriggerNode, { import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores'; import type { Node } from '@xyflow/react'; import '@testing-library/jest-dom'; +import { TriggerNodeDefaults } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.default.ts'; +import { BasicBeliefNodeDefaults } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.default.ts'; +import { defaultPlan } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/components/Plan.default.ts'; +import { NormNodeDefaults } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.default.ts'; describe('TriggerNode', () => { - let user: ReturnType; beforeEach(() => { - user = userEvent.setup(); + jest.clearAllMocks(); }); describe('Rendering', () => { @@ -26,11 +28,7 @@ describe('TriggerNode', () => { type: 'trigger', position: { x: 0, y: 0 }, data: { - label: 'Keyword Trigger', - droppable: true, - triggerType: 'keywords', - triggers: [], - hasReduce: true, + ...JSON.parse(JSON.stringify(TriggerNodeDefaults)), }, }; @@ -51,161 +49,59 @@ describe('TriggerNode', () => { /> ); - expect(screen.getByText(/Triggers when the keyword is spoken/i)).toBeInTheDocument(); - expect(screen.getByPlaceholderText('...')).toBeInTheDocument(); - }); - - it('should render TriggerNode with emotion type', () => { - const mockNode: Node = { - id: 'trigger-2', - type: 'trigger', - position: { x: 0, y: 0 }, - data: { - label: 'Emotion Trigger', - droppable: true, - triggerType: 'emotion', - triggers: [], - hasReduce: true, - }, - }; - - renderWithProviders( - - ); - - expect(screen.getByText(/Emotion\?/i)).toBeInTheDocument(); - }); - }); - - describe('User Interactions', () => { - it('should add a new keyword', async () => { - const mockNode: Node = { - id: 'trigger-1', - type: 'trigger', - position: { x: 0, y: 0 }, - data: { - label: 'Keyword Trigger', - droppable: true, - triggerType: 'keywords', - triggers: [], - hasReduce: true, - }, - }; - - useFlowStore.setState({ nodes: [mockNode], edges: [] }); - - renderWithProviders( - - ); - - const input = screen.getByPlaceholderText('...'); - await user.type(input, 'hello{enter}'); - - await waitFor(() => { - const node = useFlowStore.getState().nodes.find(n => n.id === 'trigger-1') as Node | undefined; - expect(node?.data.triggers.length).toBe(1); - expect(node?.data.triggers[0].keyword).toBe('hello'); - }); - - }); - - it('should remove a keyword when cleared', async () => { - const mockNode: Node = { - id: 'trigger-1', - type: 'trigger', - position: { x: 0, y: 0 }, - data: { - label: 'Keyword Trigger', - droppable: true, - triggerType: 'keywords', - triggers: [{ id: 'kw1', keyword: 'hello' }], - hasReduce: true, - }, - }; - - useFlowStore.setState({ nodes: [mockNode], edges: [] }); - - renderWithProviders( - - ); - - const input = screen.getByDisplayValue('hello'); - for (let i = 0; i < 'hello'.length; i++) { - await user.type(input, '{backspace}'); - } - await user.type(input, '{enter}'); - - await waitFor(() => { - const node = useFlowStore.getState().nodes.find(n => n.id === 'trigger-1') as Node | undefined; - expect(node?.data.triggers.length).toBe(0); - }); - + expect(screen.getByText(/Triggers when the condition is met/i)).toBeInTheDocument(); + expect(screen.getByText(/Belief is currently/i)).toBeInTheDocument(); + expect(screen.getByText(/Plan is currently/i)).toBeInTheDocument(); }); }); describe('TriggerReduce Function', () => { it('should reduce a trigger node to its essential data', () => { + const conditionNode: Node = { + id: 'belief-1', + type: 'basic_belief', + position: { x: 0, y: 0 }, + data: { + ...JSON.parse(JSON.stringify(BasicBeliefNodeDefaults)), + }, + }; + const triggerNode: Node = { id: 'trigger-1', type: 'trigger', position: { x: 0, y: 0 }, data: { - label: 'Keyword Trigger', - droppable: true, - triggerType: 'keywords', - triggers: [{ id: 'kw1', keyword: 'hello' }], - hasReduce: true, + ...JSON.parse(JSON.stringify(TriggerNodeDefaults)), + condition: "belief-1", + plan: defaultPlan }, }; - const allNodes: Node[] = [triggerNode]; - const result = TriggerReduce(triggerNode, allNodes); + useFlowStore.setState({ + nodes: [conditionNode, triggerNode], + edges: [], + }); + + useFlowStore.getState().onConnect({ + source: 'belief-1', + target: 'trigger-1', + sourceHandle: null, + targetHandle: null, + }); + + const result = TriggerReduce(triggerNode, useFlowStore.getState().nodes); expect(result).toEqual({ id: 'trigger-1', - type: 'keywords', - label: 'Keyword Trigger', - keywords: [{ id: 'kw1', keyword: 'hello' }], - }); + condition: { + id: "belief-1", + keyword: "help", + }, + plan: { + name: "Default Plan", + id: expect.anything(), + steps: [], + },}); }); }); @@ -217,11 +113,8 @@ describe('TriggerNode', () => { type: 'trigger', position: { x: 0, y: 0 }, data: { + ...JSON.parse(JSON.stringify(TriggerNodeDefaults)), label: 'Trigger 1', - droppable: true, - triggerType: 'keywords', - triggers: [], - hasReduce: true, }, }; @@ -230,10 +123,8 @@ describe('TriggerNode', () => { type: 'norm', position: { x: 100, y: 0 }, data: { + ...JSON.parse(JSON.stringify(NormNodeDefaults)), label: 'Norm 1', - droppable: true, - norm: 'test', - hasReduce: true, }, }; diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx index 48a3fb9..b2d6373 100644 --- a/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx @@ -187,7 +187,7 @@ describe('Universal Nodes', () => { // Verify the correct structure is present using NodesInPhase expect(result).toHaveLength(nodeType !== 'phase' ? 1 : 2); expect(result[0]).toHaveProperty('id', 'phase-1'); - expect(result[0]).toHaveProperty('label', 'Test Phase'); + expect(result[0]).toHaveProperty('name', 'Test Phase'); // Restore mocks phaseReduceSpy.mockRestore(); -- 2.49.1 From c5f44536b78469f416bd44c40535232e4ef4f81b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Sun, 4 Jan 2026 15:18:07 +0100 Subject: [PATCH 151/184] feat: seperation of concerns for gesture value editor, adjusting output of nodes, integration testing, css file changes, and probably much more. ref: N25B-412 --- src/index.css | 13 + src/pages/VisProgPage/VisProg.module.css | 14 +- .../visualProgrammingUI/VisProgStores.tsx | 26 +- .../components/DragDropSidebar.tsx | 16 +- .../components/GestureValueEditor.module.css | 164 +++++ .../components/GestureValueEditor.tsx | 579 ++++++++++++++++++ .../components/PlanEditor.tsx | 71 +-- .../visualProgrammingUI/nodes/NormNode.tsx | 2 +- 8 files changed, 809 insertions(+), 76 deletions(-) create mode 100644 src/pages/VisProgPage/visualProgrammingUI/components/GestureValueEditor.module.css create mode 100644 src/pages/VisProgPage/visualProgrammingUI/components/GestureValueEditor.tsx diff --git a/src/index.css b/src/index.css index 6e28fe5..f4e6ffe 100644 --- a/src/index.css +++ b/src/index.css @@ -59,8 +59,21 @@ button:focus-visible { background-color: #ffffff; --accent-color: #00AAAA; + --select-color: rgba(gray); + + --dropdown-menu-background-color: rgb(247, 247, 247); + --dropdown-menu-border: rgba(207, 207, 207, 0.986); } button { background-color: #f9f9f9; } } + +@media (prefers-color-scheme: dark) { + :root { + color: #ffffff; + --select-color: rgba(gray); + --dropdown-menu-background-color: rgba(39, 39, 39, 0.986); + --dropdown-menu-border: rgba(65, 65, 65, 0.986); + } +} \ No newline at end of file diff --git a/src/pages/VisProgPage/VisProg.module.css b/src/pages/VisProgPage/VisProg.module.css index 7731e42..e15db1f 100644 --- a/src/pages/VisProgPage/VisProg.module.css +++ b/src/pages/VisProgPage/VisProg.module.css @@ -141,13 +141,13 @@ } .planDialog { - width: 80vw; - max-width: 900px; - padding: 1rem; - border: none; - border-radius: 8px; + overflow:visible; + width: 80vw; + max-width: 900px; + transition: width 0.25s ease; } + .planDialog::backdrop { background: rgba(0, 0, 0, 0.4); } @@ -160,6 +160,7 @@ } .planEditorLeft { + position: relative; display: flex; flex-direction: column; gap: 0.75rem; @@ -212,4 +213,5 @@ opacity: 0.5; font-style: italic; text-decoration: line-through; -} \ No newline at end of file +} + diff --git a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx index 1decf8e..676019a 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx @@ -40,21 +40,21 @@ function createNode(id: string, type: string, position: XYPosition, data: Record ...data, }, } - } +} -//* Initial nodes, created by using createNode. */ -const initialNodes : Node[] = [ - createNode('start', 'start', {x: 100, y: 100}, {label: "Start"}, false), - createNode('end', 'end', {x: 500, y: 100}, {label: "End"}, false), - createNode('phase-1', 'phase', {x:200, y:100}, {label: "Phase 1", children : []}), - createNode('norms-1', 'norm', {x:-200, y:100}, {label: "Initial Norms", normList: ["Be a robot", "get good"], critical:false}), -]; + //* Initial nodes, created by using createNode. */ + // Start and End don't need to apply the UUID, since they are technically never compiled into a program. + const startNode = createNode('start', 'start', {x: 100, y: 100}, {label: "Start"}, false) + const endNode = createNode('end', 'end', {x: 500, y: 100}, {label: "End"}, false) + const initialPhaseNode = createNode(crypto.randomUUID(), 'phase', {x:200, y:100}, {label: "Phase 1", children : []}) -// * Initial edges * / -const initialEdges: Edge[] = [ - { id: 'start-phase-1', source: 'start', target: 'phase-1' }, - { id: 'phase-1-end', source: 'phase-1', target: 'end' }, -]; + const initialNodes : Node[] = [startNode, endNode, initialPhaseNode,]; + + // * Initial edges * / + const initialEdges: Edge[] = [ + { id: 'start-phase-1', source: startNode.id, target: initialPhaseNode.id }, + { id: 'phase-1-end', source: initialPhaseNode.id, target: endNode.id }, + ]; /** diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx index 0401da9..01e222e 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx @@ -69,23 +69,11 @@ function DraggableNode({ className, children, nodeType, onDrop }: DraggableNodeP * @param position - The XY position in the flow canvas where the node will appear. */ function addNodeToFlow(nodeType: keyof typeof NodeTypes, position: XYPosition) { - const { nodes, addNode } = useFlowStore.getState(); + const { addNode } = useFlowStore.getState(); // Load any predefined data for this node type. const defaultData = NodeDefaults[nodeType] ?? {} - - // Currently, we find out what the Id is by checking the last node and adding one. - const sameTypeNodes = nodes.filter((node) => node.type === nodeType); - const nextNumber = - sameTypeNodes.length > 0 - ? (() => { - const lastNode = sameTypeNodes[sameTypeNodes.length - 1]; - const parts = lastNode.id.split('-'); - const lastNum = Number(parts[1]); - return Number.isNaN(lastNum) ? sameTypeNodes.length + 1 : lastNum + 1; - })() - : 1; - const id = `${nodeType}-${nextNumber}`; + const id = crypto.randomUUID(); // Create new node const newNode = { diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/GestureValueEditor.module.css b/src/pages/VisProgPage/visualProgrammingUI/components/GestureValueEditor.module.css new file mode 100644 index 0000000..9d0f3e6 --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/components/GestureValueEditor.module.css @@ -0,0 +1,164 @@ + +.gestureEditor { + display: flex; + flex-direction: column; + gap: 12px; + width: 100%; +} + +.modeSelector { + display: flex; + align-items: center; + gap: 12px; +} + +.modeLabel { + font-size: 14px; + font-weight: 500; + color: var(--text-secondary); + white-space: nowrap; +} + +.toggleContainer { + display: flex; + background: rgba(78, 78, 78, 0.411); + border-radius: 6px; + padding: 2px; + border: 1px solid var(--border-color); +} + +.toggleButton { + padding: 6px 12px; + background: none; + border: none; + border-radius: 4px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + color: var(--text-secondary); +} + +.toggleButton:hover { + background: none; +} + +.toggleButton.active { + box-shadow: 0 0 1px 0 rgba(9, 255, 0, 0.733); +} + +.valueEditor { + width: 100%; +} + +.textInput { + width: 100%; + padding: 8px 12px; + border: 1px solid var(--border-color); + border-radius: 4px; + font-size: 14px; + transition: border-color 0.2s ease; +} + +.textInput:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 2px rgba(var(--primary-rgb), 0.1); +} + +.tagSelector { + display: flex; + flex-direction: column; + gap: 8px; +} + +.tagSelect { + width: 100%; + padding: 8px 12px; + border: 1px solid var(--border-color); + border-radius: 4px; + font-size: 14px; + background-color: rgba(135, 135, 135, 0.296); + cursor: pointer; +} + +.tagSelect:focus { + outline: none; + border-color: rgb(0, 149, 25); +} + +.tagList { + display: flex; + flex-wrap: wrap; + gap: 6px; + max-height: 120px; + overflow-y: auto; + padding: 4px; + border: 1px solid rgba(var(--primary-rgb), 0.1); + border-radius: 4px; + background: var(--primary-color); +} + +.tagButton { + padding: 4px 8px; + border: 1px solid gray; + border-radius: 4px; + background: var(--primary-rgb); + font-size: 12px; + cursor: pointer; + transition: all 0.2s ease; + white-space: nowrap; +} + +.tagButton:hover { + background: gray; + border-color: gray; +} + +.tagButton.selected { + background: rgba(var(--primary-rgb), 0.5); + color: var(--primary-rgb); + border-color: rgb(27, 223, 60); +} + +.suggestionsDropdownLeft { + position: absolute; + left: -220px; + top: 120px; + + width: 200px; + max-height: 20vh; + overflow-y: auto; + + background: var(--dropdown-menu-background-color); + border-radius: 12px; + box-shadow: 0 8px 24px var(--dropdown-menu-border); +} + +.suggestionsDropdownLeft::before { + content: "Gesture Suggestions"; + display: block; + padding: 8px 12px; + font-weight: 600; + border-bottom: 1px solid var(--border-light); +} + +.suggestionItem { + padding: 8px 12px; + cursor: pointer; + transition: background-color 0.2s ease; + font-size: 14px; + border-bottom: 1px solid var(--border-light); +} + +.suggestionItem:last-child { + border-bottom: none; +} + +.suggestionItem:hover { + background-color: var(--background-hover); +} + +.suggestionItem:active { + background-color: var(--primary-color-light); +} \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/GestureValueEditor.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/GestureValueEditor.tsx new file mode 100644 index 0000000..5cb76a4 --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/components/GestureValueEditor.tsx @@ -0,0 +1,579 @@ +import { useState, useEffect, useRef } from "react"; +import styles from './GestureValueEditor.module.css' + +type GestureValueEditorProps = { + value: string; + setValue: (value: string) => void; + placeholder?: string; +}; + +// Define your gesture tags here +const GESTURE_TAGS = ["above", "affirmative", "afford", "agitated", "all", "allright", "alright", "any", + "assuage", "attemper", "back", "bashful", "beg", "beseech", "blank", + "body language", "bored", "bow", "but", "call", "calm", "choose", "choice", "cloud", + "cogitate", "cool", "crazy", "disappointed", "down", "earth", "empty", "embarrassed", + "enthusiastic", "entire", "estimate", "except", "exalted", "excited", "explain", "far", + "field", "floor", "forlorn", "friendly", "front", "frustrated", "gentle", "gift", + "give", "ground", "happy", "hello", "her", "here", "hey", "hi", "him", "hopeless", + "hysterical", "I", "implore", "indicate", "joyful", "me", "meditate", "modest", + "negative", "nervous", "no", "not know", "nothing", "offer", "ok", "once upon a time", + "oppose", "or", "pacify", "pick", "placate", "please", "present", "proffer", "quiet", + "reason", "refute", "reject", "rousing", "sad", "select", "shamefaced", "show", + "show sky", "sky", "soothe", "sun", "supplicate", "tablet", "tall", "them", "there", + "think", "timid", "top", "unless", "up", "upstairs", "void", "warm", "winner", "yeah", + "yes", "yoo-hoo", "you", "your", "zero", "zestful"]; + +const GESTURE_SINGLES = [ + "animations/Stand/BodyTalk/Listening/Listening_1", + "animations/Stand/BodyTalk/Listening/Listening_2", + "animations/Stand/BodyTalk/Listening/Listening_3", + "animations/Stand/BodyTalk/Listening/Listening_4", + "animations/Stand/BodyTalk/Listening/Listening_5", + "animations/Stand/BodyTalk/Listening/Listening_6", + "animations/Stand/BodyTalk/Listening/Listening_7", + "animations/Stand/BodyTalk/Speaking/BodyTalk_1", + "animations/Stand/BodyTalk/Speaking/BodyTalk_10", + "animations/Stand/BodyTalk/Speaking/BodyTalk_11", + "animations/Stand/BodyTalk/Speaking/BodyTalk_12", + "animations/Stand/BodyTalk/Speaking/BodyTalk_13", + "animations/Stand/BodyTalk/Speaking/BodyTalk_14", + "animations/Stand/BodyTalk/Speaking/BodyTalk_15", + "animations/Stand/BodyTalk/Speaking/BodyTalk_16", + "animations/Stand/BodyTalk/Speaking/BodyTalk_2", + "animations/Stand/BodyTalk/Speaking/BodyTalk_3", + "animations/Stand/BodyTalk/Speaking/BodyTalk_4", + "animations/Stand/BodyTalk/Speaking/BodyTalk_5", + "animations/Stand/BodyTalk/Speaking/BodyTalk_6", + "animations/Stand/BodyTalk/Speaking/BodyTalk_7", + "animations/Stand/BodyTalk/Speaking/BodyTalk_8", + "animations/Stand/BodyTalk/Speaking/BodyTalk_9", + "animations/Stand/BodyTalk/Thinking/Remember_1", + "animations/Stand/BodyTalk/Thinking/Remember_2", + "animations/Stand/BodyTalk/Thinking/Remember_3", + "animations/Stand/BodyTalk/Thinking/ThinkingLoop_1", + "animations/Stand/BodyTalk/Thinking/ThinkingLoop_2", + "animations/Stand/Emotions/Negative/Angry_1", + "animations/Stand/Emotions/Negative/Angry_2", + "animations/Stand/Emotions/Negative/Angry_3", + "animations/Stand/Emotions/Negative/Angry_4", + "animations/Stand/Emotions/Negative/Anxious_1", + "animations/Stand/Emotions/Negative/Bored_1", + "animations/Stand/Emotions/Negative/Bored_2", + "animations/Stand/Emotions/Negative/Disappointed_1", + "animations/Stand/Emotions/Negative/Exhausted_1", + "animations/Stand/Emotions/Negative/Exhausted_2", + "animations/Stand/Emotions/Negative/Fear_1", + "animations/Stand/Emotions/Negative/Fear_2", + "animations/Stand/Emotions/Negative/Fearful_1", + "animations/Stand/Emotions/Negative/Frustrated_1", + "animations/Stand/Emotions/Negative/Humiliated_1", + "animations/Stand/Emotions/Negative/Hurt_1", + "animations/Stand/Emotions/Negative/Hurt_2", + "animations/Stand/Emotions/Negative/Late_1", + "animations/Stand/Emotions/Negative/Sad_1", + "animations/Stand/Emotions/Negative/Sad_2", + "animations/Stand/Emotions/Negative/Shocked_1", + "animations/Stand/Emotions/Negative/Sorry_1", + "animations/Stand/Emotions/Negative/Surprise_1", + "animations/Stand/Emotions/Negative/Surprise_2", + "animations/Stand/Emotions/Negative/Surprise_3", + "animations/Stand/Emotions/Neutral/Alienated_1", + "animations/Stand/Emotions/Neutral/AskForAttention_1", + "animations/Stand/Emotions/Neutral/AskForAttention_2", + "animations/Stand/Emotions/Neutral/AskForAttention_3", + "animations/Stand/Emotions/Neutral/Cautious_1", + "animations/Stand/Emotions/Neutral/Confused_1", + "animations/Stand/Emotions/Neutral/Determined_1", + "animations/Stand/Emotions/Neutral/Embarrassed_1", + "animations/Stand/Emotions/Neutral/Hesitation_1", + "animations/Stand/Emotions/Neutral/Innocent_1", + "animations/Stand/Emotions/Neutral/Lonely_1", + "animations/Stand/Emotions/Neutral/Mischievous_1", + "animations/Stand/Emotions/Neutral/Puzzled_1", + "animations/Stand/Emotions/Neutral/Sneeze", + "animations/Stand/Emotions/Neutral/Stubborn_1", + "animations/Stand/Emotions/Neutral/Suspicious_1", + "animations/Stand/Emotions/Positive/Amused_1", + "animations/Stand/Emotions/Positive/Confident_1", + "animations/Stand/Emotions/Positive/Ecstatic_1", + "animations/Stand/Emotions/Positive/Enthusiastic_1", + "animations/Stand/Emotions/Positive/Excited_1", + "animations/Stand/Emotions/Positive/Excited_2", + "animations/Stand/Emotions/Positive/Excited_3", + "animations/Stand/Emotions/Positive/Happy_1", + "animations/Stand/Emotions/Positive/Happy_2", + "animations/Stand/Emotions/Positive/Happy_3", + "animations/Stand/Emotions/Positive/Happy_4", + "animations/Stand/Emotions/Positive/Hungry_1", + "animations/Stand/Emotions/Positive/Hysterical_1", + "animations/Stand/Emotions/Positive/Interested_1", + "animations/Stand/Emotions/Positive/Interested_2", + "animations/Stand/Emotions/Positive/Laugh_1", + "animations/Stand/Emotions/Positive/Laugh_2", + "animations/Stand/Emotions/Positive/Laugh_3", + "animations/Stand/Emotions/Positive/Mocker_1", + "animations/Stand/Emotions/Positive/Optimistic_1", + "animations/Stand/Emotions/Positive/Peaceful_1", + "animations/Stand/Emotions/Positive/Proud_1", + "animations/Stand/Emotions/Positive/Proud_2", + "animations/Stand/Emotions/Positive/Proud_3", + "animations/Stand/Emotions/Positive/Relieved_1", + "animations/Stand/Emotions/Positive/Shy_1", + "animations/Stand/Emotions/Positive/Shy_2", + "animations/Stand/Emotions/Positive/Sure_1", + "animations/Stand/Emotions/Positive/Winner_1", + "animations/Stand/Emotions/Positive/Winner_2", + "animations/Stand/Gestures/Angry_1", + "animations/Stand/Gestures/Angry_2", + "animations/Stand/Gestures/Angry_3", + "animations/Stand/Gestures/BowShort_1", + "animations/Stand/Gestures/BowShort_2", + "animations/Stand/Gestures/BowShort_3", + "animations/Stand/Gestures/But_1", + "animations/Stand/Gestures/CalmDown_1", + "animations/Stand/Gestures/CalmDown_2", + "animations/Stand/Gestures/CalmDown_3", + "animations/Stand/Gestures/CalmDown_4", + "animations/Stand/Gestures/CalmDown_5", + "animations/Stand/Gestures/CalmDown_6", + "animations/Stand/Gestures/Choice_1", + "animations/Stand/Gestures/ComeOn_1", + "animations/Stand/Gestures/Confused_1", + "animations/Stand/Gestures/Confused_2", + "animations/Stand/Gestures/CountFive_1", + "animations/Stand/Gestures/CountFour_1", + "animations/Stand/Gestures/CountMore_1", + "animations/Stand/Gestures/CountOne_1", + "animations/Stand/Gestures/CountThree_1", + "animations/Stand/Gestures/CountTwo_1", + "animations/Stand/Gestures/Desperate_1", + "animations/Stand/Gestures/Desperate_2", + "animations/Stand/Gestures/Desperate_3", + "animations/Stand/Gestures/Desperate_4", + "animations/Stand/Gestures/Desperate_5", + "animations/Stand/Gestures/DontUnderstand_1", + "animations/Stand/Gestures/Enthusiastic_3", + "animations/Stand/Gestures/Enthusiastic_4", + "animations/Stand/Gestures/Enthusiastic_5", + "animations/Stand/Gestures/Everything_1", + "animations/Stand/Gestures/Everything_2", + "animations/Stand/Gestures/Everything_3", + "animations/Stand/Gestures/Everything_4", + "animations/Stand/Gestures/Everything_6", + "animations/Stand/Gestures/Excited_1", + "animations/Stand/Gestures/Explain_1", + "animations/Stand/Gestures/Explain_10", + "animations/Stand/Gestures/Explain_11", + "animations/Stand/Gestures/Explain_2", + "animations/Stand/Gestures/Explain_3", + "animations/Stand/Gestures/Explain_4", + "animations/Stand/Gestures/Explain_5", + "animations/Stand/Gestures/Explain_6", + "animations/Stand/Gestures/Explain_7", + "animations/Stand/Gestures/Explain_8", + "animations/Stand/Gestures/Far_1", + "animations/Stand/Gestures/Far_2", + "animations/Stand/Gestures/Far_3", + "animations/Stand/Gestures/Follow_1", + "animations/Stand/Gestures/Give_1", + "animations/Stand/Gestures/Give_2", + "animations/Stand/Gestures/Give_3", + "animations/Stand/Gestures/Give_4", + "animations/Stand/Gestures/Give_5", + "animations/Stand/Gestures/Give_6", + "animations/Stand/Gestures/Great_1", + "animations/Stand/Gestures/HeSays_1", + "animations/Stand/Gestures/HeSays_2", + "animations/Stand/Gestures/HeSays_3", + "animations/Stand/Gestures/Hey_1", + "animations/Stand/Gestures/Hey_10", + "animations/Stand/Gestures/Hey_2", + "animations/Stand/Gestures/Hey_3", + "animations/Stand/Gestures/Hey_4", + "animations/Stand/Gestures/Hey_6", + "animations/Stand/Gestures/Hey_7", + "animations/Stand/Gestures/Hey_8", + "animations/Stand/Gestures/Hey_9", + "animations/Stand/Gestures/Hide_1", + "animations/Stand/Gestures/Hot_1", + "animations/Stand/Gestures/Hot_2", + "animations/Stand/Gestures/IDontKnow_1", + "animations/Stand/Gestures/IDontKnow_2", + "animations/Stand/Gestures/IDontKnow_3", + "animations/Stand/Gestures/IDontKnow_4", + "animations/Stand/Gestures/IDontKnow_5", + "animations/Stand/Gestures/IDontKnow_6", + "animations/Stand/Gestures/Joy_1", + "animations/Stand/Gestures/Kisses_1", + "animations/Stand/Gestures/Look_1", + "animations/Stand/Gestures/Look_2", + "animations/Stand/Gestures/Maybe_1", + "animations/Stand/Gestures/Me_1", + "animations/Stand/Gestures/Me_2", + "animations/Stand/Gestures/Me_4", + "animations/Stand/Gestures/Me_7", + "animations/Stand/Gestures/Me_8", + "animations/Stand/Gestures/Mime_1", + "animations/Stand/Gestures/Mime_2", + "animations/Stand/Gestures/Next_1", + "animations/Stand/Gestures/No_1", + "animations/Stand/Gestures/No_2", + "animations/Stand/Gestures/No_3", + "animations/Stand/Gestures/No_4", + "animations/Stand/Gestures/No_5", + "animations/Stand/Gestures/No_6", + "animations/Stand/Gestures/No_7", + "animations/Stand/Gestures/No_8", + "animations/Stand/Gestures/No_9", + "animations/Stand/Gestures/Nothing_1", + "animations/Stand/Gestures/Nothing_2", + "animations/Stand/Gestures/OnTheEvening_1", + "animations/Stand/Gestures/OnTheEvening_2", + "animations/Stand/Gestures/OnTheEvening_3", + "animations/Stand/Gestures/OnTheEvening_4", + "animations/Stand/Gestures/OnTheEvening_5", + "animations/Stand/Gestures/Please_1", + "animations/Stand/Gestures/Please_2", + "animations/Stand/Gestures/Please_3", + "animations/Stand/Gestures/Reject_1", + "animations/Stand/Gestures/Reject_2", + "animations/Stand/Gestures/Reject_3", + "animations/Stand/Gestures/Reject_4", + "animations/Stand/Gestures/Reject_5", + "animations/Stand/Gestures/Reject_6", + "animations/Stand/Gestures/Salute_1", + "animations/Stand/Gestures/Salute_2", + "animations/Stand/Gestures/Salute_3", + "animations/Stand/Gestures/ShowFloor_1", + "animations/Stand/Gestures/ShowFloor_2", + "animations/Stand/Gestures/ShowFloor_3", + "animations/Stand/Gestures/ShowFloor_4", + "animations/Stand/Gestures/ShowFloor_5", + "animations/Stand/Gestures/ShowSky_1", + "animations/Stand/Gestures/ShowSky_10", + "animations/Stand/Gestures/ShowSky_11", + "animations/Stand/Gestures/ShowSky_12", + "animations/Stand/Gestures/ShowSky_2", + "animations/Stand/Gestures/ShowSky_3", + "animations/Stand/Gestures/ShowSky_4", + "animations/Stand/Gestures/ShowSky_5", + "animations/Stand/Gestures/ShowSky_6", + "animations/Stand/Gestures/ShowSky_7", + "animations/Stand/Gestures/ShowSky_8", + "animations/Stand/Gestures/ShowSky_9", + "animations/Stand/Gestures/ShowTablet_1", + "animations/Stand/Gestures/ShowTablet_2", + "animations/Stand/Gestures/ShowTablet_3", + "animations/Stand/Gestures/Shy_1", + "animations/Stand/Gestures/Stretch_1", + "animations/Stand/Gestures/Stretch_2", + "animations/Stand/Gestures/Surprised_1", + "animations/Stand/Gestures/TakePlace_1", + "animations/Stand/Gestures/TakePlace_2", + "animations/Stand/Gestures/Take_1", + "animations/Stand/Gestures/Thinking_1", + "animations/Stand/Gestures/Thinking_2", + "animations/Stand/Gestures/Thinking_3", + "animations/Stand/Gestures/Thinking_4", + "animations/Stand/Gestures/Thinking_5", + "animations/Stand/Gestures/Thinking_6", + "animations/Stand/Gestures/Thinking_7", + "animations/Stand/Gestures/Thinking_8", + "animations/Stand/Gestures/This_1", + "animations/Stand/Gestures/This_10", + "animations/Stand/Gestures/This_11", + "animations/Stand/Gestures/This_12", + "animations/Stand/Gestures/This_13", + "animations/Stand/Gestures/This_14", + "animations/Stand/Gestures/This_15", + "animations/Stand/Gestures/This_2", + "animations/Stand/Gestures/This_3", + "animations/Stand/Gestures/This_4", + "animations/Stand/Gestures/This_5", + "animations/Stand/Gestures/This_6", + "animations/Stand/Gestures/This_7", + "animations/Stand/Gestures/This_8", + "animations/Stand/Gestures/This_9", + "animations/Stand/Gestures/WhatSThis_1", + "animations/Stand/Gestures/WhatSThis_10", + "animations/Stand/Gestures/WhatSThis_11", + "animations/Stand/Gestures/WhatSThis_12", + "animations/Stand/Gestures/WhatSThis_13", + "animations/Stand/Gestures/WhatSThis_14", + "animations/Stand/Gestures/WhatSThis_15", + "animations/Stand/Gestures/WhatSThis_16", + "animations/Stand/Gestures/WhatSThis_2", + "animations/Stand/Gestures/WhatSThis_3", + "animations/Stand/Gestures/WhatSThis_4", + "animations/Stand/Gestures/WhatSThis_5", + "animations/Stand/Gestures/WhatSThis_6", + "animations/Stand/Gestures/WhatSThis_7", + "animations/Stand/Gestures/WhatSThis_8", + "animations/Stand/Gestures/WhatSThis_9", + "animations/Stand/Gestures/Whisper_1", + "animations/Stand/Gestures/Wings_1", + "animations/Stand/Gestures/Wings_2", + "animations/Stand/Gestures/Wings_3", + "animations/Stand/Gestures/Wings_4", + "animations/Stand/Gestures/Wings_5", + "animations/Stand/Gestures/Yes_1", + "animations/Stand/Gestures/Yes_2", + "animations/Stand/Gestures/Yes_3", + "animations/Stand/Gestures/YouKnowWhat_1", + "animations/Stand/Gestures/YouKnowWhat_2", + "animations/Stand/Gestures/YouKnowWhat_3", + "animations/Stand/Gestures/YouKnowWhat_4", + "animations/Stand/Gestures/YouKnowWhat_5", + "animations/Stand/Gestures/YouKnowWhat_6", + "animations/Stand/Gestures/You_1", + "animations/Stand/Gestures/You_2", + "animations/Stand/Gestures/You_3", + "animations/Stand/Gestures/You_4", + "animations/Stand/Gestures/You_5", + "animations/Stand/Gestures/Yum_1", + "animations/Stand/Reactions/EthernetOff_1", + "animations/Stand/Reactions/EthernetOn_1", + "animations/Stand/Reactions/Heat_1", + "animations/Stand/Reactions/Heat_2", + "animations/Stand/Reactions/LightShine_1", + "animations/Stand/Reactions/LightShine_2", + "animations/Stand/Reactions/LightShine_3", + "animations/Stand/Reactions/LightShine_4", + "animations/Stand/Reactions/SeeColor_1", + "animations/Stand/Reactions/SeeColor_2", + "animations/Stand/Reactions/SeeColor_3", + "animations/Stand/Reactions/SeeSomething_1", + "animations/Stand/Reactions/SeeSomething_3", + "animations/Stand/Reactions/SeeSomething_4", + "animations/Stand/Reactions/SeeSomething_5", + "animations/Stand/Reactions/SeeSomething_6", + "animations/Stand/Reactions/SeeSomething_7", + "animations/Stand/Reactions/SeeSomething_8", + "animations/Stand/Reactions/ShakeBody_1", + "animations/Stand/Reactions/ShakeBody_2", + "animations/Stand/Reactions/ShakeBody_3", + "animations/Stand/Reactions/TouchHead_1", + "animations/Stand/Reactions/TouchHead_2", + "animations/Stand/Reactions/TouchHead_3", + "animations/Stand/Reactions/TouchHead_4", + "animations/Stand/Waiting/AirGuitar_1", + "animations/Stand/Waiting/BackRubs_1", + "animations/Stand/Waiting/Bandmaster_1", + "animations/Stand/Waiting/Binoculars_1", + "animations/Stand/Waiting/BreathLoop_1", + "animations/Stand/Waiting/BreathLoop_2", + "animations/Stand/Waiting/BreathLoop_3", + "animations/Stand/Waiting/CallSomeone_1", + "animations/Stand/Waiting/Drink_1", + "animations/Stand/Waiting/DriveCar_1", + "animations/Stand/Waiting/Fitness_1", + "animations/Stand/Waiting/Fitness_2", + "animations/Stand/Waiting/Fitness_3", + "animations/Stand/Waiting/FunnyDancer_1", + "animations/Stand/Waiting/HappyBirthday_1", + "animations/Stand/Waiting/Helicopter_1", + "animations/Stand/Waiting/HideEyes_1", + "animations/Stand/Waiting/HideHands_1", + "animations/Stand/Waiting/Innocent_1", + "animations/Stand/Waiting/Knight_1", + "animations/Stand/Waiting/KnockEye_1", + "animations/Stand/Waiting/KungFu_1", + "animations/Stand/Waiting/LookHand_1", + "animations/Stand/Waiting/LookHand_2", + "animations/Stand/Waiting/LoveYou_1", + "animations/Stand/Waiting/Monster_1", + "animations/Stand/Waiting/MysticalPower_1", + "animations/Stand/Waiting/PlayHands_1", + "animations/Stand/Waiting/PlayHands_2", + "animations/Stand/Waiting/PlayHands_3", + "animations/Stand/Waiting/Relaxation_1", + "animations/Stand/Waiting/Relaxation_2", + "animations/Stand/Waiting/Relaxation_3", + "animations/Stand/Waiting/Relaxation_4", + "animations/Stand/Waiting/Rest_1", + "animations/Stand/Waiting/Robot_1", + "animations/Stand/Waiting/ScratchBack_1", + "animations/Stand/Waiting/ScratchBottom_1", + "animations/Stand/Waiting/ScratchEye_1", + "animations/Stand/Waiting/ScratchHand_1", + "animations/Stand/Waiting/ScratchHead_1", + "animations/Stand/Waiting/ScratchLeg_1", + "animations/Stand/Waiting/ScratchTorso_1", + "animations/Stand/Waiting/ShowMuscles_1", + "animations/Stand/Waiting/ShowMuscles_2", + "animations/Stand/Waiting/ShowMuscles_3", + "animations/Stand/Waiting/ShowMuscles_4", + "animations/Stand/Waiting/ShowMuscles_5", + "animations/Stand/Waiting/ShowSky_1", + "animations/Stand/Waiting/ShowSky_2", + "animations/Stand/Waiting/SpaceShuttle_1", + "animations/Stand/Waiting/Stretch_1", + "animations/Stand/Waiting/Stretch_2", + "animations/Stand/Waiting/TakePicture_1", + "animations/Stand/Waiting/Taxi_1", + "animations/Stand/Waiting/Think_1", + "animations/Stand/Waiting/Think_2", + "animations/Stand/Waiting/Think_3", + "animations/Stand/Waiting/Think_4", + "animations/Stand/Waiting/Waddle_1", + "animations/Stand/Waiting/Waddle_2", + "animations/Stand/Waiting/WakeUp_1", + "animations/Stand/Waiting/Zombie_1"] + + +export default function GestureValueEditor({ + value, + setValue, + placeholder = "Gesture name", +}: GestureValueEditorProps) { + const [mode, setMode] = useState<"single" | "tag">("tag"); + const [customValue, setCustomValue] = useState(""); + const [showSuggestions, setShowSuggestions] = useState(true); + const [filteredSuggestions, setFilteredSuggestions] = useState([]); + const containerRef = useRef(null); + + const handleModeChange = (newMode: "single" | "tag") => { + setMode(newMode); + if (newMode === "single") { + // When switching to single, use custom value or existing value + setValue(customValue || value); + setFilteredSuggestions(GESTURE_SINGLES); + setShowSuggestions(true); + } else { + // When switching to tag, clear value if not a valid tag + const isCurrentValueTag = GESTURE_TAGS.some(tag => + tag.toLowerCase() === value.toLowerCase() + ); + if (!isCurrentValueTag) { + setValue(""); + } + setShowSuggestions(false); + } + }; + + const handleTagSelect = (tag: string) => { + setValue(tag); + }; + + const handleCustomChange = (newValue: string) => { + setCustomValue(newValue); + setValue(newValue); + + // Filter suggestions based on input + if (newValue.trim() === "") { + setShowSuggestions(true) + setFilteredSuggestions(GESTURE_SINGLES); + } else { + const filtered = GESTURE_SINGLES.filter(single => + single.toLowerCase().includes(newValue.toLowerCase()) + ); + setFilteredSuggestions(filtered); + setShowSuggestions(filtered.length > 0); + } + }; + + const handleSuggestionSelect = (suggestion: string) => { + setCustomValue(suggestion); + setValue(suggestion); + setShowSuggestions(false); + }; + + const handleInputFocus = () => { + if (customValue.trim() !== "") { + const filtered = GESTURE_SINGLES.filter(tag => + tag.toLowerCase().includes(customValue.toLowerCase()) + ); + setFilteredSuggestions(filtered); + setShowSuggestions(filtered.length > 0); + } + }; + + const handleInputBlur = (_e: React.FocusEvent) => { + // Delay hiding suggestions to allow clicking on them + + }; + + return ( +
    + {/* Mode selector */} +
    + +
    + + +
    +
    + + {/* Value editor based on mode */} +
    + {mode === "single" ? ( +
    + {showSuggestions && ( +
    + {filteredSuggestions.map((suggestion) => ( +
    handleSuggestionSelect(suggestion)} + onMouseDown={(e) => e.preventDefault()} + > + {suggestion} +
    + ))} +
    + )} + handleCustomChange(e.target.value)} + onFocus={handleInputFocus} + onBlur={handleInputBlur} + placeholder={placeholder} + className={`${styles.textInput} ${showSuggestions ? styles.textInputWithSuggestions : ''}`} + autoComplete="off" + /> +
    + ) : ( +
    + +
    + {GESTURE_TAGS.map((tag) => ( + + ))} +
    +
    + )} +
    +
    + ); +} \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/PlanEditor.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/PlanEditor.tsx index af05310..7092a95 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/PlanEditor.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/PlanEditor.tsx @@ -3,6 +3,7 @@ import styles from '../../VisProg.module.css'; import { GetActionValue, type Action, type ActionTypes, type Plan } from "../components/Plan"; import { defaultPlan } from "../components/Plan.default"; import { TextField } from "../../../../components/TextField"; +import GestureValueEditor from "./GestureValueEditor"; // Add this import type PlanEditorDialogProps = { plan?: Plan; @@ -10,25 +11,6 @@ type PlanEditorDialogProps = { description? : string; }; -/** - * Adds an element to a React.JSX.Element that allows for the creation and editing of plans. - * Renders a dialog in the current screen with buttons and text fields for names, actions and other configurability. - * @param param0: Takes in a current plan, which can be undefined and a function which is called on saving with the potential plan. - * @returns: JSX.Element - * @example - * ``` - * // Within a Node's default JSX Element function - * { - * updateNodeData(props.id, { - * ...data, - * plan, - * }); - * }} - * /> - * ``` - */ export default function PlanEditorDialog({ plan, onSave, @@ -53,8 +35,6 @@ export default function PlanEditorDialog({ dialogRef.current?.showModal(); } - - const openEdit = () => { if (!plan) return; setDraftPlan(structuredClone(plan)); @@ -93,9 +73,9 @@ export default function PlanEditorDialog({ {/* Start of dialog (plan editor) */} e.preventDefault()} + ref={dialogRef} + className={`${styles.planDialog}`} + onWheel={(e) => e.stopPropagation()} >

    {draftPlan?.id === plan?.id ? "Edit Plan" : "Create Plan"}

    @@ -124,26 +104,34 @@ export default function PlanEditorDialog({ {/* Type selection */} - {/* Action value editor */} - + {/* Action value editor - UPDATED SECTION */} + {newActionType === "gesture" ? ( + + ) : ( + + )} {/* Adding steps */}
    ); -} +} \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx index ef65215..fb7a251 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx @@ -102,7 +102,7 @@ export function NormReduce(node: Node, nodes: Node[]) { const conditionNode = nodes.find((node) => node.id === data.condition); // In case something went wrong, and our condition doesn't actually exist; if (conditionNode == undefined) return result; - result["belief"] = reducer(conditionNode, nodes) + result["condition"] = reducer(conditionNode, nodes) } return result -- 2.49.1 From 149b82cb66630e3589960a7bad87513f9f04dbbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Sun, 4 Jan 2026 18:29:19 +0100 Subject: [PATCH 152/184] feat: create tests, more integration testing, fix ID tests, use UUID (almost) everywhere ref: N25B-412 --- src/pages/VisProgPage/VisProg.module.css | 72 +--------- .../components/GestureValueEditor.tsx | 90 +++++++----- .../visualProgrammingUI/components/Plan.tsx | 1 - .../components/PlanEditor.module.css | 71 ++++++++++ .../components/PlanEditor.tsx | 19 ++- .../components/DragDropSidebar.test.tsx | 6 +- .../components/GestureValueEditor.test.tsx | 131 ++++++++++++++++++ .../components/PlanEditor.test.tsx | 50 +++---- .../nodes/NormNode.test.tsx | 18 ++- .../nodes/PhaseNode.test.tsx | 6 +- test/setupFlowTests.ts | 14 ++ 11 files changed, 332 insertions(+), 146 deletions(-) create mode 100644 src/pages/VisProgPage/visualProgrammingUI/components/PlanEditor.module.css create mode 100644 test/pages/visProgPage/visualProgrammingUI/components/GestureValueEditor.test.tsx diff --git a/src/pages/VisProgPage/VisProg.module.css b/src/pages/VisProgPage/VisProg.module.css index e15db1f..429e740 100644 --- a/src/pages/VisProgPage/VisProg.module.css +++ b/src/pages/VisProgPage/VisProg.module.css @@ -140,78 +140,8 @@ filter: drop-shadow(0 0 0.25rem plum); } -.planDialog { - overflow:visible; - width: 80vw; - max-width: 900px; - transition: width 0.25s ease; -} - - -.planDialog::backdrop { - background: rgba(0, 0, 0, 0.4); -} - -.planEditor { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 1rem; - min-width: 600px; -} - -.planEditorLeft { - position: relative; - display: flex; - flex-direction: column; - gap: 0.75rem; -} - -.planEditorRight { - display: flex; - flex-direction: column; - gap: 0.5rem; - border-left: 1px solid var(--border-color, #ccc); - padding-left: 1rem; - max-height: 300px; - overflow-y: auto; -} - -.planStep { - display: flex; - align-items: center; - gap: 0.5rem; - cursor: pointer; - transition: text-decoration 0.2s; -} - - -.planStep:hover { - text-decoration: line-through; -} - -.stepType { - opacity: 0.7; - font-size: 0.85em; -} - - -.stepIndex { - opacity: 0.6; -} - -.emptySteps { - opacity: 0.5; - font-style: italic; -} - -.stepSuggestion { - opacity: 0.5; - font-style: italic; -} - .planNoIterate { opacity: 0.5; font-style: italic; text-decoration: line-through; -} - +} \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/GestureValueEditor.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/GestureValueEditor.tsx index 5cb76a4..67f9f16 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/GestureValueEditor.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/GestureValueEditor.tsx @@ -1,13 +1,23 @@ -import { useState, useEffect, useRef } from "react"; +import { useState, useRef } from "react"; import styles from './GestureValueEditor.module.css' +/** + * Props for the GestureValueEditor component. + * - value: current gesture value (controlled by parent) + * - setValue: callback to update the gesture value in parent state + * - placeholder: optional placeholder text for the input field + */ type GestureValueEditorProps = { value: string; setValue: (value: string) => void; placeholder?: string; }; -// Define your gesture tags here +/** + * List of high-level gesture "tags". + * These are human-readable categories or semantic labels. + * In a real app, these would likely be loaded from an external source. + */ const GESTURE_TAGS = ["above", "affirmative", "afford", "agitated", "all", "allright", "alright", "any", "assuage", "attemper", "back", "bashful", "beg", "beseech", "blank", "body language", "bored", "bow", "but", "call", "calm", "choose", "choice", "cloud", @@ -23,6 +33,11 @@ const GESTURE_TAGS = ["above", "affirmative", "afford", "agitated", "all", "allr "think", "timid", "top", "unless", "up", "upstairs", "void", "warm", "winner", "yeah", "yes", "yoo-hoo", "you", "your", "zero", "zestful"]; +/** + * List of concrete gesture animation paths. + * These represent specific animation assets and are used in "single" mode + * with autocomplete-style selection, also would be loaded from an external source. + */ const GESTURE_SINGLES = [ "animations/Stand/BodyTalk/Listening/Listening_1", "animations/Stand/BodyTalk/Listening/Listening_2", @@ -421,50 +436,62 @@ const GESTURE_SINGLES = [ "animations/Stand/Waiting/Zombie_1"] +/** + * Returns a gesture value editor component. + * @returns JSX.Element + */ export default function GestureValueEditor({ value, setValue, placeholder = "Gesture name", }: GestureValueEditorProps) { + + /** Input mode: semantic tag vs concrete animation path */ const [mode, setMode] = useState<"single" | "tag">("tag"); + + /** Raw text value for single-gesture input */ const [customValue, setCustomValue] = useState(""); + + /** Autocomplete dropdown state */ const [showSuggestions, setShowSuggestions] = useState(true); const [filteredSuggestions, setFilteredSuggestions] = useState([]); + + /** Reserved for future click-outside / positioning logic */ const containerRef = useRef(null); + /** Switch between tag and single input modes */ const handleModeChange = (newMode: "single" | "tag") => { setMode(newMode); + if (newMode === "single") { - // When switching to single, use custom value or existing value setValue(customValue || value); setFilteredSuggestions(GESTURE_SINGLES); setShowSuggestions(true); } else { - // When switching to tag, clear value if not a valid tag - const isCurrentValueTag = GESTURE_TAGS.some(tag => - tag.toLowerCase() === value.toLowerCase() + // Clear value if it does not match a valid tag + const isValidTag = GESTURE_TAGS.some( + tag => tag.toLowerCase() === value.toLowerCase() ); - if (!isCurrentValueTag) { - setValue(""); - } + if (!isValidTag) setValue(""); setShowSuggestions(false); } }; + /** Select a semantic gesture tag */ const handleTagSelect = (tag: string) => { setValue(tag); }; + /** Update single-gesture input and filter suggestions */ const handleCustomChange = (newValue: string) => { setCustomValue(newValue); setValue(newValue); - - // Filter suggestions based on input + if (newValue.trim() === "") { - setShowSuggestions(true) setFilteredSuggestions(GESTURE_SINGLES); + setShowSuggestions(true); } else { - const filtered = GESTURE_SINGLES.filter(single => + const filtered = GESTURE_SINGLES.filter(single => single.toLowerCase().includes(newValue.toLowerCase()) ); setFilteredSuggestions(filtered); @@ -472,30 +499,32 @@ export default function GestureValueEditor({ } }; + /** Commit autocomplete selection */ const handleSuggestionSelect = (suggestion: string) => { setCustomValue(suggestion); setValue(suggestion); setShowSuggestions(false); }; + /** Refresh suggestions on refocus */ const handleInputFocus = () => { - if (customValue.trim() !== "") { - const filtered = GESTURE_SINGLES.filter(tag => - tag.toLowerCase().includes(customValue.toLowerCase()) - ); - setFilteredSuggestions(filtered); - setShowSuggestions(filtered.length > 0); - } + if (!customValue.trim()) return; + + const filtered = GESTURE_SINGLES.filter(single => + single.toLowerCase().includes(customValue.toLowerCase()) + ); + setFilteredSuggestions(filtered); + setShowSuggestions(filtered.length > 0); }; - const handleInputBlur = (_e: React.FocusEvent) => { - // Delay hiding suggestions to allow clicking on them + /** Exists to allow delayed blur handling if needed */ + const handleInputBlur = (_e: React.FocusEvent) => {}; - }; + /** Build the JSX component */ return (
    - {/* Mode selector */} + {/* Mode toggle */}
    @@ -516,8 +545,7 @@ export default function GestureValueEditor({
    - {/* Value editor based on mode */} -
    +
    {mode === "single" ? (
    {showSuggestions && ( @@ -527,7 +555,7 @@ export default function GestureValueEditor({ key={suggestion} className={styles.suggestionItem} onClick={() => handleSuggestionSelect(suggestion)} - onMouseDown={(e) => e.preventDefault()} + onMouseDown={(e) => e.preventDefault()} // prevent blur before click > {suggestion}
    @@ -551,14 +579,14 @@ export default function GestureValueEditor({ value={value} onChange={(e) => handleTagSelect(e.target.value)} className={styles.tagSelect} + data-testid={"tagSelectorTestID"} > - + {GESTURE_TAGS.map((tag) => ( - + ))} +
    {GESTURE_TAGS.map((tag) => ( + )} + {plan && ( + + )} - const close = () => { - dialogRef.current?.close(); - setDraftPlan(null); - }; + {/* Start of dialog (plan editor) */} + e.stopPropagation()} + data-testid={"PlanEditorDialogTestID"} + > + +

    {draftPlan?.id === plan?.id ? "Edit Plan" : "Create Plan"}

    + {/* Plan name text field */} + {draftPlan && ( + + setDraftPlan({ ...draftPlan, name })} + placeholder="Plan name" + data-testid="name_text_field"/> + )} - const buildAction = (): Action => { - const id = crypto.randomUUID(); - switch (newActionType) { - case "speech": - return { id, text: newActionValue, type: "speech" }; - case "gesture": - return { id, gesture: newActionValue, type: "gesture" }; - case "llm": - return { id, goal: newActionValue, type: "llm" }; - } - }; + {/* Entire "bottom" part (adder and steps) without cancel, confirm and reset */} + {draftPlan && (
    +
    + {/* Left Side (Action Adder) */} +

    Add Action

    + {(!plan && description && draftPlan.steps.length === 0) && (
    + + +
    )} + - return (<> - {/* Create and edit buttons */} - {!plan && ( - - )} - {plan && ( - - )} - - {/* Start of dialog (plan editor) */} - e.stopPropagation()} - data-testid={"PlanEditorDialogTestID"} - > - -

    {draftPlan?.id === plan?.id ? "Edit Plan" : "Create Plan"}

    - {/* Plan name text field */} - {draftPlan && ( - - setDraftPlan({ ...draftPlan, name })} - placeholder="Plan name" - data-testid="name_text_field"/> - )} - - {/* Entire "bottom" part (adder and steps) without cancel, confirm and reset */} - {draftPlan && (
    -
    - {/* Left Side (Action Adder) */} -

    Add Action

    - {(!plan && description && draftPlan.steps.length === 0) && (
    - - -
    )} - - - {/* Action value editor*/} - {newActionType === "gesture" ? ( - // Gesture get their own editor component - + ) : ( + - ) : ( - - )} - - {/* Adding steps */} - -
    - - {/* Right Side (Steps shown) */} -
    -

    Steps

    + placeholder={ + newActionType === "speech" ? "Speech text" + : "LLM goal" + } + /> + )} - {/* Show if there are no steps yet */} - {draftPlan.steps.length === 0 && ( -
    - No steps yet -
    - )} - - - {/* Map over all steps */} - {draftPlan.steps.map((step, index) => ( -
    { - if (e.key === "Enter" || e.key === " ") { - setDraftPlan({ - ...draftPlan, - steps: draftPlan.steps.filter((s) => s.id !== step.id),}); - }}} - onClick={() => { - setDraftPlan({ - ...draftPlan, - steps: draftPlan.steps.filter((s) => s.id !== step.id),}); - }}> - - {index + 1}. - {step.type}: - { - step.type == "goal" ? ""/* TODO: Add support for goals */ - : GetActionValue(step)} - -
    - ))} -
    + {/* Adding steps */} +
    - )} - {/* Buttons */} -
    - {/* Close button */} - + {/* Right Side (Steps shown) */} +
    +

    Steps

    - {/* Confirm/ Create button */} - + {/* Show if there are no steps yet */} + {draftPlan.steps.length === 0 && ( +
    + No steps yet +
    + )} - {/* Reset button */} - -
    - -
    - - ); + + {/* Map over all steps */} + {draftPlan.steps.map((step, index) => ( +
    { + if (e.key === "Enter" || e.key === " ") { + setDraftPlan({ + ...draftPlan, + steps: draftPlan.steps.filter((s) => s.id !== step.id),}); + }}} + onClick={() => { + setDraftPlan({ + ...draftPlan, + steps: draftPlan.steps.filter((s) => s.id !== step.id),}); + }}> + + {index + 1}. + {step.type}: + { + step.type == "goal" ? ""/* TODO: Add support for goals */ + : GetActionValue(step)} + +
    + ))} +
    +
    + )} + + {/* Buttons */} +
    + {/* Close button */} + + + {/* Confirm/ Create button */} + + + {/* Reset button */} + +
    + +
    + +); } \ No newline at end of file -- 2.49.1 From 216b136a759e2b1252ce260835c84bde2eb5d2f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Mon, 5 Jan 2026 16:38:06 +0100 Subject: [PATCH 158/184] chore: change goal text, correct output for gestures, allow step specific reducing, fix tests/ add tests for new things --- .../components/GestureValueEditor.tsx | 4 + .../visualProgrammingUI/components/Plan.tsx | 37 ++++++++- .../components/PlanEditor.tsx | 4 +- .../visualProgrammingUI/nodes/GoalNode.tsx | 2 +- .../components/GestureValueEditor.test.tsx | 5 +- .../components/PlanEditor.test.tsx | 78 ++++++++++++++++++- 6 files changed, 122 insertions(+), 8 deletions(-) diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/GestureValueEditor.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/GestureValueEditor.tsx index 67f9f16..3b5863a 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/GestureValueEditor.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/GestureValueEditor.tsx @@ -10,6 +10,7 @@ import styles from './GestureValueEditor.module.css' type GestureValueEditorProps = { value: string; setValue: (value: string) => void; + setType: (value: boolean) => void; placeholder?: string; }; @@ -443,6 +444,7 @@ const GESTURE_SINGLES = [ export default function GestureValueEditor({ value, setValue, + setType, placeholder = "Gesture name", }: GestureValueEditorProps) { @@ -465,10 +467,12 @@ export default function GestureValueEditor({ if (newMode === "single") { setValue(customValue || value); + setType(false); setFilteredSuggestions(GESTURE_SINGLES); setShowSuggestions(true); } else { // Clear value if it does not match a valid tag + setType(true); const isValidTag = GESTURE_TAGS.some( tag => tag.toLowerCase() === value.toLowerCase() ); diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/Plan.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/Plan.tsx index 864c27a..00fa88f 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/Plan.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/Plan.tsx @@ -17,7 +17,7 @@ export type Goal = { // Actions export type Action = SpeechAction | GestureAction | LLMAction export type SpeechAction = { id: string, text: string, type:"speech" } -export type GestureAction = { id: string, gesture: string, type:"gesture" } +export type GestureAction = { id: string, gesture: string, isTag: boolean, type:"gesture" } export type LLMAction = { id: string, goal: string, type:"llm" } export type ActionTypes = "speech" | "gesture" | "llm"; @@ -29,7 +29,40 @@ export function PlanReduce(plan?: Plan) { return { name: plan.name, id: plan.id, - steps: plan.steps, + steps: plan.steps.map((x) => StepReduce(x)) + } +} + + +// Extract the wanted information from a plan element. +function StepReduce(planElement: PlanElement) { + // We have different types of plan elements, requiring differnt types of output + switch (planElement.type) { + case ("speech"): + return { + id: planElement.id, + text: planElement.text, + } + case ("gesture"): + return { + id: planElement.id, + gesture: { + type: planElement.isTag ? "tag" : "single", + name: planElement.gesture + }, + } + case ("llm"): + return { + id: planElement.id, + goal: planElement.goal, + } + case ("goal"): + return { + id: planElement.id, + plan: planElement.plan, + can_fail: planElement.can_fail, + }; + default: } } diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/PlanEditor.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/PlanEditor.tsx index ac41241..19b590b 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/PlanEditor.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/PlanEditor.tsx @@ -21,6 +21,7 @@ export default function PlanEditorDialog({ const dialogRef = useRef(null); const [draftPlan, setDraftPlan] = useState(null); const [newActionType, setNewActionType] = useState("speech"); + const [newActionGestureType, setNewActionGestureType] = useState(true); const [newActionValue, setNewActionValue] = useState(""); const { setScrollable } = useFlowStore(); @@ -58,7 +59,7 @@ export default function PlanEditorDialog({ case "speech": return { id, text: newActionValue, type: "speech" }; case "gesture": - return { id, gesture: newActionValue, type: "gesture" }; + return { id, gesture: newActionValue, isTag: newActionGestureType, type: "gesture" }; case "llm": return { id, goal: newActionValue, type: "llm" }; } @@ -127,6 +128,7 @@ export default function PlanEditorDialog({ ) : ( diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx index 75b8b99..ad48a7d 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx @@ -65,7 +65,7 @@ export default function GoalNode({id, data}: NodeProps) {
    - +
    {data.plan && (
    {planIterate ? "" : } diff --git a/test/pages/visProgPage/visualProgrammingUI/components/GestureValueEditor.test.tsx b/test/pages/visProgPage/visualProgrammingUI/components/GestureValueEditor.test.tsx index fc67f25..3bbc205 100644 --- a/test/pages/visProgPage/visualProgrammingUI/components/GestureValueEditor.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/components/GestureValueEditor.test.tsx @@ -3,10 +3,11 @@ import userEvent from '@testing-library/user-event'; import { renderWithProviders, screen } from '../../../../test-utils/test-utils.tsx'; import GestureValueEditor from '../../../../../src/pages/VisProgPage/visualProgrammingUI/components/GestureValueEditor'; -function TestHarness({ initialValue = '', placeholder = 'Gesture name' } : { initialValue?: string, placeholder?: string }) { +function TestHarness({ initialValue = '', initialType=true, placeholder = 'Gesture name' } : { initialValue?: string, initialType?: boolean, placeholder?: string }) { const [value, setValue] = useState(initialValue); + const [_, setType] = useState(initialType) return ( - + ); } diff --git a/test/pages/visProgPage/visualProgrammingUI/components/PlanEditor.test.tsx b/test/pages/visProgPage/visualProgrammingUI/components/PlanEditor.test.tsx index a4979a5..63d5cfa 100644 --- a/test/pages/visProgPage/visualProgrammingUI/components/PlanEditor.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/components/PlanEditor.test.tsx @@ -4,7 +4,7 @@ import { screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { renderWithProviders } from '../../../../test-utils/test-utils.tsx'; import PlanEditorDialog from '../../../../../src/pages/VisProgPage/visualProgrammingUI/components/PlanEditor'; -import type { Plan } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/components/Plan'; +import { PlanReduce, type Plan } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/components/Plan'; import '@testing-library/jest-dom'; // Mock structuredClone @@ -28,12 +28,48 @@ describe('PlanEditorDialog', () => { steps: [], }; + const extendedPlan: Plan = { + id: 'extended-plan-1', + name: 'extended test plan', + steps: [ + // Step 1: A wave tag gesture + { + id: 'firststep', + type: 'gesture', + isTag: true, + gesture: "hello" + }, + + // Step 2: A single tag gesture + { + id: 'secondstep', + type: 'gesture', + isTag: false, + gesture: "somefolder/somegesture" + }, + + // Step 3: A LLM action + { + id: 'thirdstep', + type: 'llm', + goal: 'ask the user something or whatever' + }, + + // Step 4: A speech action + { + id: 'fourthstep', + type: 'speech', + text: "I'm a cyborg ninja :>" + }, + ] + } + const planWithSteps: Plan = { id: 'plan-2', name: 'Existing Plan', steps: [ { id: 'step-1', text: 'Hello world', type: 'speech' as const }, - { id: 'step-2', gesture: 'Wave', type: 'gesture' as const }, + { id: 'step-2', gesture: 'Wave', isTag:true, type: 'gesture' as const }, ], }; @@ -429,4 +465,42 @@ describe('PlanEditorDialog', () => { expect(llmInput).toBeInTheDocument(); }); }); + + describe('Plan reducing', () => { + it('should correctly reduce the plan given the elements of the plan', () => { + const testplan = extendedPlan + const expectedResult = { + name: "extended test plan", + id: "extended-plan-1", + steps: [ + { + id: "firststep", + gesture: { + type: "tag", + name: "hello" + } + }, + { + id: "secondstep", + gesture: { + type: "single", + name: "somefolder/somegesture" + } + }, + { + id: "thirdstep", + goal: "ask the user something or whatever" + }, + { + id: "fourthstep", + text: "I'm a cyborg ninja :>" + } + ] + } + + const actualResult = PlanReduce(testplan) + + expect(actualResult).toEqual(expectedResult) + }); + }) }); \ No newline at end of file -- 2.49.1 From bd93b04bfd6248868239a542a778bd565f53246b Mon Sep 17 00:00:00 2001 From: "Gerla, J. (Justin)" Date: Tue, 6 Jan 2026 12:27:22 +0000 Subject: [PATCH 159/184] feat: made (reduced) program data available on all pages --- src/pages/VisProgPage/VisProg.tsx | 5 ++ src/utils/programStore.ts | 81 +++++++++++++++++++++ test/setupFlowTests.ts | 5 ++ test/utils/programStore.test.ts | 116 ++++++++++++++++++++++++++++++ 4 files changed, 207 insertions(+) create mode 100644 src/utils/programStore.ts create mode 100644 test/utils/programStore.test.ts diff --git a/src/pages/VisProgPage/VisProg.tsx b/src/pages/VisProgPage/VisProg.tsx index 06e072c..1a3720b 100644 --- a/src/pages/VisProgPage/VisProg.tsx +++ b/src/pages/VisProgPage/VisProg.tsx @@ -9,6 +9,7 @@ import { import '@xyflow/react/dist/style.css'; import {useEffect} from "react"; import {useShallow} from 'zustand/react/shallow'; +import useProgramStore from "../../utils/programStore.ts"; import {DndToolbar} from './visualProgrammingUI/components/DragDropSidebar.tsx'; import useFlowStore from './visualProgrammingUI/VisProgStores.tsx'; import type {FlowState} from './visualProgrammingUI/VisProgTypes.tsx'; @@ -152,6 +153,10 @@ function runProgram() { ).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.")); } diff --git a/src/utils/programStore.ts b/src/utils/programStore.ts new file mode 100644 index 0000000..e6bcc3a --- /dev/null +++ b/src/utils/programStore.ts @@ -0,0 +1,81 @@ +import {create} from "zustand"; + +// the type of a reduced program +export type ReducedProgram = { phases: Record[] }; + +/** + * the type definition of the programStore + */ +export type ProgramState = { + // Basic store functionality: + currentProgram: ReducedProgram; + setProgramState: (state: ReducedProgram) => void; + getProgramState: () => ReducedProgram; + + // Utility functions: + // to avoid having to manually go through the entire state for every instance where data is required + getPhaseIds: () => string[]; + getNormsInPhase: (currentPhaseId: string) => Record[]; + getGoalsInPhase: (currentPhaseId: string) => Record[]; + getTriggersInPhase: (currentPhaseId: string) => Record[]; + // if more specific utility functions are needed they can be added here: +} + +/** + * the ProgramStore can be used to access all information of the most recently sent program, + * it contains basic functions to set and get the current program. + * And it contains some utility functions that allow you to easily gain access + * to the norms, triggers and goals of a specific phase. + */ +const useProgramStore = create((set, get) => ({ + currentProgram: { phases: [] as Record[]}, + /** + * sets the current program by cloning the provided program using a structuredClone + */ + setProgramState: (program: ReducedProgram) => set({currentProgram: structuredClone(program)}), + /** + * gets the current program + */ + getProgramState: () => get().currentProgram, + + // utility functions: + /** + * gets the ids of all phases in the program + */ + getPhaseIds: () => get().currentProgram.phases.map(entry => entry["id"] as string), + /** + * gets the norms for the provided phase + */ + getNormsInPhase: (currentPhaseId) => { + const program = get().currentProgram; + const phase = program.phases.find(val => val["id"] === currentPhaseId); + if (phase) { + return phase["norms"] as Record[]; + } + throw new Error(`phase with id:"${currentPhaseId}" not found`) + }, + /** + * gets the goals for the provided phase + */ + getGoalsInPhase: (currentPhaseId) => { + const program = get().currentProgram; + const phase = program.phases.find(val => val["id"] === currentPhaseId); + if (phase) { + return phase["goals"] as Record[]; + } + throw new Error(`phase with id:"${currentPhaseId}" not found`) + }, + /** + * gets the triggers for the provided phase + */ + getTriggersInPhase: (currentPhaseId) => { + const program = get().currentProgram; + const phase = program.phases.find(val => val["id"] === currentPhaseId); + if (phase) { + return phase["triggers"] as Record[]; + } + throw new Error(`phase with id:"${currentPhaseId}" not found`) + } +})); + +export default useProgramStore; \ No newline at end of file diff --git a/test/setupFlowTests.ts b/test/setupFlowTests.ts index 3ce8c3a..c37cd0e 100644 --- a/test/setupFlowTests.ts +++ b/test/setupFlowTests.ts @@ -2,6 +2,11 @@ import '@testing-library/jest-dom'; import { cleanup } from '@testing-library/react'; import useFlowStore from '../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx'; +if (!globalThis.structuredClone) { + globalThis.structuredClone = (obj: any) => { + return JSON.parse(JSON.stringify(obj)); + }; +} // To make sure that the tests are working, it's important that you are using // this implementation of ResizeObserver and DOMMatrixReadOnly diff --git a/test/utils/programStore.test.ts b/test/utils/programStore.test.ts new file mode 100644 index 0000000..ba78b88 --- /dev/null +++ b/test/utils/programStore.test.ts @@ -0,0 +1,116 @@ +import useProgramStore, {type ReducedProgram} from "../../src/utils/programStore.ts"; + + +describe('useProgramStore', () => { + beforeEach(() => { + // Reset store before each test + useProgramStore.setState({ + currentProgram: { phases: [] }, + }); + }); + + const mockProgram: ReducedProgram = { + phases: [ + { + id: 'phase-1', + norms: [{ id: 'norm-1' }], + goals: [{ id: 'goal-1' }], + triggers: [{ id: 'trigger-1' }], + }, + { + id: 'phase-2', + norms: [{ id: 'norm-2' }], + goals: [{ id: 'goal-2' }], + triggers: [{ id: 'trigger-2' }], + }, + ], + }; + + it('should set and get the program state', () => { + useProgramStore.getState().setProgramState(mockProgram); + + const program = useProgramStore.getState().getProgramState(); + expect(program).toEqual(mockProgram); + }); + + it('should return the ids of all phases in the program', () => { + useProgramStore.getState().setProgramState(mockProgram); + + const phaseIds = useProgramStore.getState().getPhaseIds(); + expect(phaseIds).toEqual(['phase-1', 'phase-2']); + }); + + it('should return all norms for a given phase', () => { + useProgramStore.getState().setProgramState(mockProgram); + + const norms = useProgramStore.getState().getNormsInPhase('phase-1'); + expect(norms).toEqual([{ id: 'norm-1' }]); + }); + + it('should return all goals for a given phase', () => { + useProgramStore.getState().setProgramState(mockProgram); + + const goals = useProgramStore.getState().getGoalsInPhase('phase-2'); + expect(goals).toEqual([{ id: 'goal-2' }]); + }); + + it('should return all triggers for a given phase', () => { + useProgramStore.getState().setProgramState(mockProgram); + + const triggers = useProgramStore.getState().getTriggersInPhase('phase-1'); + expect(triggers).toEqual([{ id: 'trigger-1' }]); + }); + + it('throws if phase does not exist when getting norms', () => { + useProgramStore.getState().setProgramState(mockProgram); + + expect(() => + useProgramStore.getState().getNormsInPhase('missing-phase') + ).toThrow('phase with id:"missing-phase" not found'); + }); + + it('throws if phase does not exist when getting goals', () => { + useProgramStore.getState().setProgramState(mockProgram); + + expect(() => + useProgramStore.getState().getGoalsInPhase('missing-phase') + ).toThrow('phase with id:"missing-phase" not found'); + }); + + it('throws if phase does not exist when getting triggers', () => { + useProgramStore.getState().setProgramState(mockProgram); + + expect(() => + useProgramStore.getState().getTriggersInPhase('missing-phase') + ).toThrow('phase with id:"missing-phase" not found'); + }); + + it('should clone program state when setting it (no shared references should exist)', () => { + const changeableMockProgram: ReducedProgram = { + phases: [ + { + id: 'phase-1', + norms: [{ id: 'norm-1' }], + goals: [{ id: 'goal-1' }], + triggers: [{ id: 'trigger-1' }], + }, + { + id: 'phase-2', + norms: [{ id: 'norm-2' }], + goals: [{ id: 'goal-2' }], + triggers: [{ id: 'trigger-2' }], + }, + ], + }; + + useProgramStore.getState().setProgramState(changeableMockProgram); + + const storedProgram = useProgramStore.getState().getProgramState(); + + // mutate original + (changeableMockProgram.phases[0].norms as any[]).push({ id: 'norm-mutated' }); + + // store should NOT change + expect(storedProgram.phases[0]['norms']).toHaveLength(1); + }); +}); \ No newline at end of file -- 2.49.1 From 508fa48be67c4256f79409f7753d0e0ee6cdf923 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Tue, 6 Jan 2026 14:47:56 +0100 Subject: [PATCH 160/184] fix: fix the goal node's "can_fail" to have the correct property. --- .../visualProgrammingUI/nodes/GoalNode.default.ts | 2 +- .../visualProgrammingUI/nodes/GoalNode.tsx | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.default.ts b/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.default.ts index 4cf314c..88e4951 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.default.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.default.ts @@ -9,5 +9,5 @@ export const GoalNodeDefaults: GoalNodeData = { description: "", achieved: false, hasReduce: true, - can_fail: true, + can_fail: false, }; \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx index ad48a7d..42f9bde 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx @@ -17,6 +17,8 @@ import PlanEditorDialog from '../components/PlanEditor'; * @param droppable: whether this node is droppable from the drop bar (initialized as true) * @param desciption: description of the goal * @param hasReduce: whether this node has reducing functionality (true by default) + * @param can_fail: whether this plan should be checked- this plan could possible fail + * @param plan: The (possible) attached plan to this goal */ export type GoalNodeData = { label: string; @@ -69,13 +71,13 @@ export default function GoalNode({id, data}: NodeProps) {
    {data.plan && (
    {planIterate ? "" : } - + planIterate ? setFailable(e.target.checked) : undefined} + onChange={(e) => planIterate ? setFailable(e.target.checked) : setFailable(false)} />
    )} @@ -107,9 +109,8 @@ export function GoalReduce(node: Node, _nodes: Node[]) { const data = node.data as GoalNodeData; return { id: node.id, - name: data.label, - description: data.description, - can_fail: data.can_fail, + name: data.description, + can_fail: data.can_fail, plan: data.plan ? PlanReduce(data.plan) : "", } } -- 2.49.1 From f4745c736f33e75bbda57fcb13649907dad9c5b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Tue, 6 Jan 2026 15:28:31 +0100 Subject: [PATCH 161/184] refactor: update the goal node to have a description for plans that need to be checked, and correctly give the value to the CB. ref: N25B-412 --- src/components/MultilineTextField.tsx | 75 +++++++++++++++++++ src/components/TextField.module.css | 10 +++ .../nodes/GoalNode.default.ts | 1 + .../visualProgrammingUI/nodes/GoalNode.tsx | 32 ++++++-- 4 files changed, 111 insertions(+), 7 deletions(-) create mode 100644 src/components/MultilineTextField.tsx diff --git a/src/components/MultilineTextField.tsx b/src/components/MultilineTextField.tsx new file mode 100644 index 0000000..ad88513 --- /dev/null +++ b/src/components/MultilineTextField.tsx @@ -0,0 +1,75 @@ +import { useEffect, useRef, useState } from "react"; +import styles from "./TextField.module.css"; + +export function MultilineTextField({ + value = "", + setValue, + placeholder, + className, + id, + ariaLabel, + invalid = false, + minRows = 3, +}: { + value: string; + setValue: (value: string) => void; + placeholder?: string; + className?: string; + id?: string; + ariaLabel?: string; + invalid?: boolean; + minRows?: number; +}) { + const [readOnly, setReadOnly] = useState(true); + const [inputValue, setInputValue] = useState(value); + const textareaRef = useRef(null); + + useEffect(() => { + setInputValue(value); + }, [value]); + + // Auto-grow logic + useEffect(() => { + const el = textareaRef.current; + if (!el) return; + + el.style.height = "auto"; + el.style.height = `${el.scrollHeight}px`; + }, [inputValue]); + + const onCommit = () => { + setReadOnly(true); + setValue(inputValue); + }; + + const onKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { + e.preventDefault(); + (e.target as HTMLTextAreaElement).blur(); + } + }; + + return ( + + + + {/* FOOTER */} +
    + + + +
    +
    + ); +} + +export default MonitoringPage; \ No newline at end of file diff --git a/src/pages/MonitoringPage/MonitoringPageAPI.ts b/src/pages/MonitoringPage/MonitoringPageAPI.ts new file mode 100644 index 0000000..c210968 --- /dev/null +++ b/src/pages/MonitoringPage/MonitoringPageAPI.ts @@ -0,0 +1,121 @@ +import React, { useEffect } from 'react'; + +const API_BASE = "http://localhost:8000"; +const API_BASE_BP = API_BASE + "/button_pressed"; //UserInterruptAgent endpoint + +/** + * HELPER: Unified sender function + */ +export const sendAPICall = async (type: string, context: string, endpoint?: string) => { + try { + const response = await fetch(`${API_BASE_BP}${endpoint ?? ""}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ type, context }), + }); + if (!response.ok) throw new Error("Backend response error"); + console.log(`API Call send - Type: ${type}, Context: ${context} ${endpoint ? `, Endpoint: ${endpoint}` : ""}`); + } catch (err) { + console.error(`Failed to send api call:`, err); + } +}; + + +/** + * Sends an API call to the CB for going to the next phase. + * In case we can't go to the next phase, the function will throw an error. + */ +export async function nextPhase(): Promise { + const type = "next_phase" + const context = "" + sendAPICall(type, context) +} + + +/** + * Sends an API call to the CB for going to pause experiment +*/ +export async function pauseExperiment(): Promise { + const type = "pause" + const context = "true" + sendAPICall(type, context) +} + +/** + * Sends an API call to the CB for going to resume experiment +*/ +export async function playExperiment(): Promise { + const type = "pause" + const context = "false" + sendAPICall(type, context) +} + + +/** + * Types for the experiment stream messages + */ +export type PhaseUpdate = { type: 'phase_update'; id: string }; +export type GoalUpdate = { type: 'goal_update'; id: string }; +export type TriggerUpdate = { type: 'trigger_update'; id: string; achieved: boolean }; +export type CondNormsStateUpdate = { type: 'cond_norms_state_update'; norms: { id: string; active: boolean }[] }; +export type ExperimentStreamData = PhaseUpdate | GoalUpdate | TriggerUpdate | CondNormsStateUpdate | Record; + +/** + * A hook that listens to the experiment stream that updates current state of the program + * via updates sent from the backend + */ +export function useExperimentLogger(onUpdate?: (data: ExperimentStreamData) => void) { + const callbackRef = React.useRef(onUpdate); + // Ref is updated every time with on update + React.useEffect(() => { + callbackRef.current = onUpdate; + }, [onUpdate]); + + useEffect(() => { + console.log("Connecting to Experiment Stream..."); + const eventSource = new EventSource(`${API_BASE}/experiment_stream`); + + eventSource.onmessage = (event) => { + try { + const parsedData = JSON.parse(event.data) as ExperimentStreamData; + //call function using the ref + callbackRef.current?.(parsedData); + } catch (err) { + console.warn("Stream parse error:", err); + } + }; + + eventSource.onerror = (err) => { + console.error("SSE Connection Error:", err); + eventSource.close(); + }; + + return () => { + console.log("Closing Experiment Stream..."); + eventSource.close(); + }; + }, []); +} + +/** + * A hook that listens to the status stream that updates active conditional norms + * via updates sent from the backend + */ +export function useStatusLogger(onUpdate?: (data: ExperimentStreamData) => void) { + const callbackRef = React.useRef(onUpdate); + + React.useEffect(() => { + callbackRef.current = onUpdate; + }, [onUpdate]); + + useEffect(() => { + const eventSource = new EventSource(`${API_BASE}/status_stream`); + eventSource.onmessage = (event) => { + try { + const parsedData = JSON.parse(event.data); + callbackRef.current?.(parsedData); + } catch (err) { console.warn("Status stream error:", err); } + }; + return () => eventSource.close(); + }, []); +} \ No newline at end of file diff --git a/src/pages/MonitoringPage/MonitoringPageComponents.tsx b/src/pages/MonitoringPage/MonitoringPageComponents.tsx new file mode 100644 index 0000000..d1d2854 --- /dev/null +++ b/src/pages/MonitoringPage/MonitoringPageComponents.tsx @@ -0,0 +1,232 @@ +import React, { useEffect, useState } from 'react'; +import styles from './MonitoringPage.module.css'; +import { sendAPICall } from './MonitoringPageAPI'; + +// --- GESTURE COMPONENT --- +export const GestureControls: React.FC = () => { + const [selectedGesture, setSelectedGesture] = useState("animations/Stand/BodyTalk/Speaking/BodyTalk_1"); + + const gestures = [ + { label: "Wave", value: "animations/Stand/Gestures/Hey_1" }, + { label: "Think", value: "animations/Stand/Emotions/Neutral/Puzzled_1" }, + { label: "Explain", value: "animations/Stand/Gestures/Explain_4" }, + { label: "You", value: "animations/Stand/Gestures/You_1" }, + { label: "Happy", value: "animations/Stand/Emotions/Positive/Happy_1" }, + { label: "Laugh", value: "animations/Stand/Emotions/Positive/Laugh_2" }, + { label: "Lonely", value: "animations/Stand/Emotions/Neutral/Lonely_1" }, + { label: "Suprise", value: "animations/Stand/Emotions/Negative/Surprise_1" }, + { label: "Hurt", value: "animations/Stand/Emotions/Negative/Hurt_2" }, + { label: "Angry", value: "animations/Stand/Emotions/Negative/Angry_4" }, + ]; + return ( +
    +

    Gestures

    +
    + + +
    +
    + ); +}; + +// --- PRESET SPEECH COMPONENT --- +export const SpeechPresets: React.FC = () => { + const phrases = [ + { label: "Hello, I'm Pepper", text: "Hello, I'm Pepper" }, + { label: "Repeat please", text: "Could you repeat that please" }, + { label: "About yourself", text: "Tell me something about yourself" }, + ]; + + return ( +
    +

    Speech Presets

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

    Direct Pepper Speech

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

    {title}

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

    Connection:

    +

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

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

    Warnings

    +
    + {(['ALL', 'ERROR', 'WARNING', 'INFO'] as const).map(severity => ( + + ))} +
    +
    + ); +} + + +/** + * the list of warnings in the warning sidebar + * + * @param {{warnings: EditorWarning[]}} props + * @returns {React.JSX.Element} + * @constructor + */ +function WarningsList(props: { warnings: EditorWarning[] }) { + const splitWarnings = { + global: props.warnings.filter(w => w.scope.id === globalWarning), + other: props.warnings.filter(w => w.scope.id !== globalWarning), + } + if (props.warnings.length === 0) { + return ( +
    + No warnings! +
    + ) + } + return ( +
    +
    global:
    +
    + {splitWarnings.global.map((warning) => ( + + ))} + {splitWarnings.global.length === 0 && "No global warnings!"} +
    +
    other:
    +
    + {splitWarnings.other.map((warning) => ( + + ))} + {splitWarnings.other.length === 0 && "No other warnings!"} +
    +
    + ); +} + +/** + * a single warning in the warning sidebar + * + * @param {{warning: EditorWarning, key: string}} props + * @returns {React.JSX.Element} + * @constructor + */ +function WarningListItem(props: { warning: EditorWarning, key: string}) { + const jumpToNode = useJumpToNode(); + + return ( +
    jumpToNode(props.warning.scope.id)} + > +
    + {props.warning.type} +
    + +
    + {props.warning.description} +
    +
    + ); +} + +/** + * moves the editor to the provided node + * @returns {(nodeId: string) => void} + */ +function useJumpToNode() { + const { getNode, setCenter, getViewport } = useReactFlow(); + const { addSelectedNodes } = useStoreApi().getState(); + + + return (nodeId: string) => { + // user can't jump to global warning, so prevent further logic from running if the warning is a globalWarning + if (nodeId === globalWarning) return; + const node = getNode(nodeId); + if (!node) return; + + const nodeElement = document.querySelector(`.react-flow__node[data-id="${nodeId}"]`) as HTMLElement; + const { position } = node; + const viewport = getViewport(); + const { width, height } = nodeElement.getBoundingClientRect(); + + //move to node + setCenter( + position!.x + ((width / viewport.zoom) / 2), + position!.y + ((height / viewport.zoom) / 2), + {duration: 300, interpolate: "smooth" } + ).then(() => { + addSelectedNodes([nodeId]); + }); + + + + }; +} \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx index 5348b06..4495745 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx @@ -113,9 +113,7 @@ export default function BasicBeliefNode(props: NodeProps) { updateNodeData(props.id, {...data, belief: {...data.belief, description: value}}); } - // Use this - const emotionOptions = ["Happy", "Angry", "Sad", "Cheerful"] - + const emotionOptions = ["sad", "angry", "surprise", "fear", "happy", "disgust", "neutral"]; let placeholder = "" let wrapping = "" @@ -191,8 +189,8 @@ export default function BasicBeliefNode(props: NodeProps) { )} + allowOnlyConnectionsFromHandle([{nodeType:"trigger",handleId:"TriggerBeliefs"}, {nodeType:"norm",handleId:"NormBeliefs"},{nodeType:"InferredBelief",handleId:"inferred_belief"}]), + ]} title="Connect to any number of trigger and/or normNode(-s)"/>
    ); diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx index 5c456b5..3bbfa14 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx @@ -1,12 +1,15 @@ import { type NodeProps, Position, - type Node, + type Node, useNodeConnections } from '@xyflow/react'; +import {useEffect} from "react"; +import type {EditorWarning} from "../components/EditorWarnings.tsx"; import { Toolbar } from '../components/NodeComponents'; import styles from '../../VisProg.module.css'; import {SingleConnectionHandle} from "../components/RuleBasedHandle.tsx"; import {allowOnlyConnectionsFromType} from "../HandleRules.ts"; +import useFlowStore from "../VisProgStores.tsx"; @@ -27,6 +30,27 @@ export type EndNode = Node * @returns React.JSX.Element */ export default function EndNode(props: NodeProps) { + const {registerWarning, unregisterWarning} = useFlowStore.getState(); + const connections = useNodeConnections({ + id: props.id, + handleId: 'target' + }) + + useEffect(() => { + const noConnectionWarning : EditorWarning = { + scope: { + id: props.id, + handleId: 'target' + }, + type: 'MISSING_INPUT', + severity: "ERROR", + description: "the endNode does not have an incoming connection from a phaseNode" + } + + if (connections.length === 0) { registerWarning(noConnectionWarning); } + else { unregisterWarning(props.id, `${noConnectionWarning.type}:target`); } + }, [connections.length, props.id, registerWarning, unregisterWarning]); + return ( <> @@ -36,7 +60,7 @@ export default function EndNode(props: NodeProps) {
    + ]} title="Connect to a phaseNode"/>
    ); diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx index fea9914..1974e99 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx @@ -1,8 +1,10 @@ import { type NodeProps, Position, - type Node, + type Node } from '@xyflow/react'; +import {useEffect} from "react"; +import type {EditorWarning} from "../components/EditorWarnings.tsx"; import { Toolbar } from '../components/NodeComponents'; import styles from '../../VisProg.module.css'; import { TextField } from '../../../../components/TextField'; @@ -44,7 +46,7 @@ export type GoalNode = Node * @returns React.JSX.Element */ export default function GoalNode({id, data}: NodeProps) { - const {updateNodeData} = useFlowStore(); + const {updateNodeData, registerWarning, unregisterWarning} = useFlowStore(); const _nodes = useFlowStore().nodes; const text_input_id = `goal_${id}_text_input`; @@ -64,6 +66,24 @@ export default function GoalNode({id, data}: NodeProps) { updateNodeData(id, {...data, can_fail: value}); } + + useEffect(() => { + const noPlanWarning : EditorWarning = { + scope: { + id: id, + handleId: undefined + }, + type: 'PLAN_IS_UNDEFINED', + severity: 'ERROR', + description: "This goalNode is missing a plan, please make sure to create a plan by using the create plan button" + }; + + if (!data.plan){ + registerWarning(noPlanWarning); + return; + } + unregisterWarning(id, noPlanWarning.type); + },[data.plan, id, registerWarning, unregisterWarning]) return <>
    @@ -118,9 +138,11 @@ export default function GoalNode({id, data}: NodeProps) {
    + ]} title="Connect to any number of phase and/or goalNode(-s)"/> - +
    diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.tsx index be5d4ec..924517b 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.tsx @@ -1,6 +1,7 @@ -import {getConnectedEdges, type Node, type NodeProps, Position} from '@xyflow/react'; -import {useState} from "react"; +import {getConnectedEdges, type Node, type NodeProps, Position, useNodeConnections} from '@xyflow/react'; +import {useEffect, useState} from "react"; import styles from '../../VisProg.module.css'; +import type {EditorWarning} from "../components/EditorWarnings.tsx"; import {Toolbar} from '../components/NodeComponents.tsx'; import {MultiConnectionHandle, SingleConnectionHandle} from "../components/RuleBasedHandle.tsx"; import {allowOnlyConnectionsFromType} from "../HandleRules.ts"; @@ -91,7 +92,7 @@ export const InferredBeliefTooltip = ` */ export default function InferredBeliefNode(props: NodeProps) { const data = props.data; - const { updateNodeData } = useFlowStore(); + const { updateNodeData, registerWarning, unregisterWarning } = useFlowStore(); // start of as an AND operator, true: "AND", false: "OR" const [enforceAllBeliefs, setEnforceAllBeliefs] = useState(true); @@ -109,6 +110,29 @@ export default function InferredBeliefNode(props: NodeProps) }); } + const beliefConnections = useNodeConnections({ + id: props.id, + handleType: "target", + }) + + useEffect(() => { + const noBeliefsWarning : EditorWarning = { + scope: { + id: props.id, + handleId: undefined + }, + type: 'MISSING_INPUT', + severity: 'ERROR', + description: `This AND/OR node is missing one or more beliefs, + please make sure to use both inputs of an AND/OR node` + }; + + if (beliefConnections.length < 2){ + registerWarning(noBeliefsWarning); + return; + } + unregisterWarning(props.id, noBeliefsWarning.type); + },[beliefConnections.length, props.id, registerWarning, unregisterWarning]) return ( <> diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx index 8ee5462..29a03df 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx @@ -79,10 +79,10 @@ export default function NormNode(props: NodeProps) { + ]} title="Connect to any number of phaseNode(-s)"/> + ]} title="Connect to a beliefNode or a set of beliefs combined using the AND/OR node"/>
    ; }; diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx index 50e81b6..e5f2b9b 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx @@ -1,8 +1,10 @@ import { type NodeProps, Position, - type Node + type Node, useNodeConnections } from '@xyflow/react'; +import {useEffect, useRef} from "react"; +import {type EditorWarning} from "../components/EditorWarnings.tsx"; import { Toolbar } from '../components/NodeComponents'; import styles from '../../VisProg.module.css'; import {SingleConnectionHandle, MultiConnectionHandle} from "../components/RuleBasedHandle.tsx"; @@ -37,10 +39,107 @@ export type PhaseNode = Node */ export default function PhaseNode(props: NodeProps) { const data = props.data; - const {updateNodeData} = useFlowStore(); + const {updateNodeData, registerWarning, unregisterWarning} = useFlowStore(); const updateLabel = (value: string) => updateNodeData(props.id, {...data, label: value}); const label_input_id = `phase_${props.id}_label_input`; + const connections = useNodeConnections({ + id: props.id, + handleType: "target", + handleId: 'data' + }) + + const phaseOutCons = useNodeConnections({ + id: props.id, + handleType: "source", + handleId: 'source', + }) + + const phaseInCons = useNodeConnections({ + id: props.id, + handleType: "target", + handleId: 'target', + }) + + + + useEffect(() => { + const noConnectionWarning : EditorWarning = { + scope: { + id: props.id, + handleId: 'data' + }, + type: 'MISSING_INPUT', + severity: "WARNING", + description: "the phaseNode has no incoming goals, norms, and/or triggers" + } + + if (connections.length === 0) { registerWarning(noConnectionWarning); return; } + unregisterWarning(props.id, `${noConnectionWarning.type}:data`); + }, [connections.length, props.id, registerWarning, unregisterWarning]); + + useEffect(() => { + const notConnectedInfo : EditorWarning = { + scope: { + id: props.id, + handleId: undefined, + }, + type: 'NOT_CONNECTED_TO_PROGRAM', + severity: "INFO", + description: "The PhaseNode is not connected to other nodes" + }; + const noIncomingPhaseWarning : EditorWarning = { + scope: { + id: props.id, + handleId: 'target' + }, + type: 'MISSING_INPUT', + severity: "WARNING", + description: "the phaseNode has no incoming connection from a phase or the startNode" + } + const noOutgoingPhaseWarning : EditorWarning = { + scope: { + id: props.id, + handleId: 'source' + }, + type: 'MISSING_OUTPUT', + severity: "WARNING", + description: "the phaseNode has no outgoing connection to a phase or the endNode" + } + + // register relevant warning and unregister others + if (phaseInCons.length === 0 && phaseOutCons.length === 0) { + registerWarning(notConnectedInfo); + unregisterWarning(props.id, `${noOutgoingPhaseWarning.type}:${noOutgoingPhaseWarning.scope.handleId}`); + unregisterWarning(props.id, `${noIncomingPhaseWarning.type}:${noIncomingPhaseWarning.scope.handleId}`); + return; + } + if (phaseOutCons.length === 0) { + registerWarning(noOutgoingPhaseWarning); + unregisterWarning(props.id, `${noIncomingPhaseWarning.type}:${noIncomingPhaseWarning.scope.handleId}`); + unregisterWarning(notConnectedInfo.scope.id, notConnectedInfo.type); + return; + } + if (phaseInCons.length === 0) { + registerWarning(noIncomingPhaseWarning); + unregisterWarning(props.id, `${noOutgoingPhaseWarning.type}:${noOutgoingPhaseWarning.scope.handleId}`); + unregisterWarning(notConnectedInfo.scope.id, notConnectedInfo.type); + return; + } + // unregister all warnings if none should be present + unregisterWarning(notConnectedInfo.scope.id, notConnectedInfo.type); + unregisterWarning(props.id, `${noOutgoingPhaseWarning.type}:${noOutgoingPhaseWarning.scope.handleId}`); + unregisterWarning(props.id, `${noIncomingPhaseWarning.type}:${noIncomingPhaseWarning.scope.handleId}`); + }, [phaseInCons.length, phaseOutCons.length, props.id, registerWarning, unregisterWarning]); + const ref = useRef(null); + + useEffect(() => { + if (ref.current) { + const { width, height } = ref.current.getBoundingClientRect(); + + console.log('Node width:', width, 'height:', height); + } + }, []); return ( <> @@ -57,14 +156,14 @@ export default function PhaseNode(props: NodeProps) { + ]} title="Connect to a phase or the startNode"/> + ]} title="Connect to any number of norm, goal, and TriggerNode(-s)"/> + ]} title="Connect to a phase or the endNode"/>
    + ]} title="Connect to a phaseNode"/>
    ); diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx index 3004fe8..caa8c58 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx @@ -1,8 +1,10 @@ import { type NodeProps, Position, - type Node, + type Node, useNodeConnections } from '@xyflow/react'; +import {useEffect} from "react"; +import type {EditorWarning} from "../components/EditorWarnings.tsx"; import { Toolbar } from '../components/NodeComponents'; import styles from '../../VisProg.module.css'; import {MultiConnectionHandle, SingleConnectionHandle} from "../components/RuleBasedHandle.tsx"; @@ -45,12 +47,77 @@ export type TriggerNode = Node */ export default function TriggerNode(props: NodeProps) { const data = props.data; - const {updateNodeData} = useFlowStore(); + const {updateNodeData, registerWarning, unregisterWarning} = useFlowStore(); const setName= (value: string) => { updateNodeData(props.id, {...data, name: value}) } + const beliefInput = useNodeConnections({ + id: props.id, + handleType: "target", + handleId: "TriggerBeliefs" + }) + + const outputCons = useNodeConnections({ + id: props.id, + handleType: "source", + handleId: "TriggerSource" + }) + + useEffect(() => { + const noPhaseConnectionWarning : EditorWarning = { + scope: { + id: props.id, + handleId: 'TriggerSource' + }, + type: 'MISSING_OUTPUT', + severity: 'INFO', + description: "This triggerNode is missing a condition/belief, please make sure to connect a belief node to " + }; + + if (outputCons.length === 0){ + registerWarning(noPhaseConnectionWarning); + return; + } + unregisterWarning(props.id, `${noPhaseConnectionWarning.type}:${noPhaseConnectionWarning.scope.handleId}`); + },[outputCons.length, props.id, registerWarning, unregisterWarning]) + + useEffect(() => { + const noBeliefWarning : EditorWarning = { + scope: { + id: props.id, + handleId: 'TriggerBeliefs' + }, + type: 'MISSING_INPUT', + severity: 'ERROR', + description: "This triggerNode is missing a condition/belief, please make sure to connect a belief node to " + }; + + if (beliefInput.length === 0 && outputCons.length !== 0){ + registerWarning(noBeliefWarning); + return; + } + unregisterWarning(props.id, `${noBeliefWarning.type}:${noBeliefWarning.scope.handleId}`); + },[beliefInput.length, outputCons.length, props.id, registerWarning, unregisterWarning]) + + useEffect(() => { + const noPlanWarning : EditorWarning = { + scope: { + id: props.id, + handleId: undefined + }, + type: 'PLAN_IS_UNDEFINED', + severity: 'ERROR', + description: "This triggerNode is missing a plan, please make sure to create a plan by using the create plan button" + }; + + if (!data.plan && outputCons.length !== 0){ + registerWarning(noPlanWarning); + return; + } + unregisterWarning(props.id, noPlanWarning.type); + },[data.plan, outputCons.length, props.id, registerWarning, unregisterWarning]) return <> @@ -65,15 +132,16 @@ export default function TriggerNode(props: NodeProps) {
    Plan{data.plan ? (": " + data.plan.name) : ""} is currently {data.plan ? "" : "not"} set. {data.plan ? "🟢" : "🔴"}
    + ]} title="Connect to any number of phaseNodes"/> ) { rules={[ allowOnlyConnectionsFromType(['goal']), ]} + title="Connect to any number of goalNodes" /> [] }; +export type GoalWithDepth = Record & { level: number }; + /** * the type definition of the programStore */ @@ -15,8 +17,10 @@ export type ProgramState = { // Utility functions: // to avoid having to manually go through the entire state for every instance where data is required getPhaseIds: () => string[]; + getPhaseNames: () => string[]; getNormsInPhase: (currentPhaseId: string) => Record[]; getGoalsInPhase: (currentPhaseId: string) => Record[]; + getGoalsWithDepth: (currentPhaseId: string) => GoalWithDepth[]; getTriggersInPhase: (currentPhaseId: string) => Record[]; // if more specific utility functions are needed they can be added here: } @@ -43,6 +47,10 @@ const useProgramStore = create((set, get) => ({ * gets the ids of all phases in the program */ getPhaseIds: () => get().currentProgram.phases.map(entry => entry["id"] as string), + /** + * gets the names of all phases in the program + */ + getPhaseNames: () => get().currentProgram.phases.map((entry) => (entry["name"] as string)), /** * gets the norms for the provided phase */ @@ -65,6 +73,50 @@ const useProgramStore = create((set, get) => ({ } throw new Error(`phase with id:"${currentPhaseId}" not found`) }, + + getGoalsWithDepth: (currentPhaseId: string) => { + const program = get().currentProgram; + const phase = program.phases.find(val => val["id"] === currentPhaseId); + + if (!phase) { + throw new Error(`phase with id:"${currentPhaseId}" not found`); + } + + const rootGoals = phase["goals"] as Record[]; + const flatList: GoalWithDepth[] = []; + + const isGoal = (item: Record) => { + return item["plan"] !== undefined; + }; + + // Recursive helper function + const traverse = (goals: Record[], depth: number) => { + goals.forEach((goal) => { + // 1. Add the current goal to the list + flatList.push({ ...goal, level: depth }); + + // 2. Check for children + const plan = goal["plan"] as Record | undefined; + + if (plan && Array.isArray(plan["steps"])) { + const steps = plan["steps"] as Record[]; + + // 3. FILTER: Only recurse on steps that are actually goals + // If we just passed 'steps', we might accidentally add Actions/Speeches to the goal list + const childGoals = steps.filter(isGoal); + + if (childGoals.length > 0) { + traverse(childGoals, depth + 1); + } + } + }); + }; + + // Start traversal + traverse(rootGoals, 0); + + return flatList; + }, /** * gets the triggers for the provided phase */ diff --git a/test/pages/monitoringPage/MonitoringPage.test.tsx b/test/pages/monitoringPage/MonitoringPage.test.tsx new file mode 100644 index 0000000..482372a --- /dev/null +++ b/test/pages/monitoringPage/MonitoringPage.test.tsx @@ -0,0 +1,299 @@ +import { render, screen, fireEvent, act } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import MonitoringPage from '../../../src/pages/MonitoringPage/MonitoringPage'; +import useProgramStore from '../../../src/utils/programStore'; +import * as MonitoringAPI from '../../../src/pages/MonitoringPage/MonitoringPageAPI'; +import * as VisProg from '../../../src/pages/VisProgPage/VisProgLogic'; + +// --- Mocks --- + +// Mock the Zustand store +jest.mock('../../../src/utils/programStore', () => ({ + __esModule: true, + default: jest.fn(), +})); + +// Mock the API layer including hooks +jest.mock('../../../src/pages/MonitoringPage/MonitoringPageAPI', () => ({ + nextPhase: jest.fn(), + resetPhase: jest.fn(), + pauseExperiment: jest.fn(), + playExperiment: jest.fn(), + // We mock these to capture the callbacks and trigger them manually in tests + useExperimentLogger: jest.fn(), + useStatusLogger: jest.fn(), +})); + +// Mock VisProg functionality +jest.mock('../../../src/pages/VisProgPage/VisProgLogic', () => ({ + graphReducer: jest.fn(), + runProgramm: jest.fn(), +})); + +// Mock Child Components to reduce noise (optional, but keeps unit test focused) +// For this test, we will allow them to render to test data passing, +// but we mock RobotConnected as it has its own side effects +jest.mock('../../../src/pages/MonitoringPage/MonitoringPageComponents', () => { + const original = jest.requireActual('../../../src/pages/MonitoringPage/MonitoringPageComponents'); + return { + ...original, + RobotConnected: () =>
    Robot Status
    , + }; +}); + +describe('MonitoringPage', () => { + // Capture stream callbacks + let streamUpdateCallback: (data: any) => void; + let statusUpdateCallback: (data: any) => void; + + // Setup default store state + const mockGetPhaseIds = jest.fn(); + const mockGetPhaseNames = jest.fn(); + const mockGetNorms = jest.fn(); + const mockGetGoals = jest.fn(); + const mockGetGoalsWithDepth = jest.fn(); + const mockGetTriggers = jest.fn(); + const mockSetProgramState = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + + // Default Store Implementation + (useProgramStore as unknown as jest.Mock).mockImplementation((selector) => { + const state = { + getPhaseIds: mockGetPhaseIds, + getPhaseNames: mockGetPhaseNames, + getNormsInPhase: mockGetNorms, + getGoalsInPhase: mockGetGoals, + getTriggersInPhase: mockGetTriggers, + getGoalsWithDepth: mockGetGoalsWithDepth, + setProgramState: mockSetProgramState, + }; + return selector(state); + }); + + // Capture the hook callbacks + (MonitoringAPI.useExperimentLogger as jest.Mock).mockImplementation((cb) => { + streamUpdateCallback = cb; + }); + (MonitoringAPI.useStatusLogger as jest.Mock).mockImplementation((cb) => { + statusUpdateCallback = cb; + }); + + // Default mock return values + mockGetPhaseIds.mockReturnValue(['phase-1', 'phase-2']); + mockGetPhaseNames.mockReturnValue(['Intro', 'Main']); + mockGetGoals.mockReturnValue([{ id: 'g1', name: 'Goal 1'}, { id: 'g2', name: 'Goal 2'}]); + mockGetGoalsWithDepth.mockReturnValue([ + { id: 'g1', name: 'Goal 1', level: 0 }, + { id: 'g2', name: 'Goal 2', level: 0 } + ]); + mockGetTriggers.mockReturnValue([{ id: 't1', name: 'Trigger 1' }]); + mockGetNorms.mockReturnValue([ + { id: 'n1', norm: 'Norm 1', condition: null }, + { id: 'cn1', norm: 'Cond Norm 1', condition: 'some-cond' } + ]); + }); + + test('renders "No program loaded" when phaseIds are empty', () => { + mockGetPhaseIds.mockReturnValue([]); + render(); + expect(screen.getByText('No program loaded.')).toBeInTheDocument(); + }); + + test('renders the dashboard with initial state', () => { + render(); + + // Check Header + expect(screen.getByText('Phase 1:')).toBeInTheDocument(); + expect(screen.getByText('Intro')).toBeInTheDocument(); + + // Check Lists + expect(screen.getByText(/Goal 1/)).toBeInTheDocument(); + + expect(screen.getByText('Trigger 1')).toBeInTheDocument(); + expect(screen.getByText('Norm 1')).toBeInTheDocument(); + expect(screen.getByText('Cond Norm 1')).toBeInTheDocument(); + }); + + describe('Control Buttons', () => { + test('Pause calls API and updates UI', async () => { + render(); + const pauseBtn = screen.getByText('❚❚'); + + await act(async () => { + fireEvent.click(pauseBtn); + }); + + expect(MonitoringAPI.pauseExperiment).toHaveBeenCalled(); + // Ensure local state toggled (we check if play button is now inactive style or pause active) + }); + + test('Play calls API and updates UI', async () => { + render(); + const playBtn = screen.getByText('▶'); + + await act(async () => { + fireEvent.click(playBtn); + }); + + expect(MonitoringAPI.playExperiment).toHaveBeenCalled(); + }); + + test('Next Phase calls API', async () => { + render(); + await act(async () => { + fireEvent.click(screen.getByText('⏭')); + }); + expect(MonitoringAPI.nextPhase).toHaveBeenCalled(); + }); + + test('Reset Experiment calls logic and resets state', async () => { + render(); + + // Mock graph reducer return + (VisProg.graphReducer as jest.Mock).mockReturnValue([{ id: 'new-phase' }]); + + await act(async () => { + fireEvent.click(screen.getByText('⟲')); + }); + + expect(VisProg.graphReducer).toHaveBeenCalled(); + expect(mockSetProgramState).toHaveBeenCalledWith({ phases: [{ id: 'new-phase' }] }); + expect(VisProg.runProgramm).toHaveBeenCalled(); + }); + + test('Reset Experiment handles errors gracefully', async () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + (VisProg.runProgramm as jest.Mock).mockRejectedValue(new Error('Fail')); + + render(); + await act(async () => { + fireEvent.click(screen.getByText('⟲')); + }); + + expect(consoleSpy).toHaveBeenCalledWith('Failed to reset program:', expect.any(Error)); + consoleSpy.mockRestore(); + }); + }); + + describe('Stream Updates (useExperimentLogger)', () => { + test('Handles phase_update to next phase', () => { + render(); + + expect(screen.getByText('Intro')).toBeInTheDocument(); // Phase 0 + + act(() => { + streamUpdateCallback({ type: 'phase_update', id: 'phase-2' }); + }); + + expect(screen.getByText('Main')).toBeInTheDocument(); // Phase 1 + }); + + test('Handles phase_update to "end"', () => { + render(); + + act(() => { + streamUpdateCallback({ type: 'phase_update', id: 'end' }); + }); + + expect(screen.getByText('Experiment finished')).toBeInTheDocument(); + expect(screen.getByText('All phases have been successfully completed.')).toBeInTheDocument(); + }); + + test('Handles phase_update with unknown ID gracefully', () => { + render(); + act(() => { + streamUpdateCallback({ type: 'phase_update', id: 'unknown-phase' }); + }); + // Should remain on current phase + expect(screen.getByText('Intro')).toBeInTheDocument(); + }); + + test('Handles goal_update: advances index and marks previous as achieved', () => { + render(); + + // Initial: Goal 1 (index 0) is current. + // Send update for Goal 2 (index 1). + act(() => { + streamUpdateCallback({ type: 'goal_update', id: 'g2' }); + }); + + // Goal 1 should now be marked achieved (passed via activeIds) + // Goal 2 should be current. + + // We can inspect the "StatusList" props implicitly by checking styling or indicators if not mocked, + // but since we render the full component, we check the class/text. + // Goal 1 should have checkmark (override logic puts checkmark for activeIds) + // The implementation details of StatusList show ✔️ for activeIds. + + const items = screen.getAllByRole('listitem'); + // Helper to find checkmarks within items + expect(items[0]).toHaveTextContent('Goal 1'); + // After update, g1 is active (achieved), g2 is current + // logic: loop i < gIndex (1). activeIds['g1'] = true. + }); + + test('Handles goal_update with unknown ID', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + render(); + act(() => { + streamUpdateCallback({ type: 'goal_update', id: 'unknown-goal' }); + }); + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Goal unknown-goal not found')); + warnSpy.mockRestore(); + }); + + test('Handles trigger_update', () => { + render(); + + // Trigger 1 initially not achieved + act(() => { + streamUpdateCallback({ type: 'trigger_update', id: 't1', achieved: true }); + }); + + // StatusList logic: if activeId is true, show ✔️ + // We look for visual confirmation or check logic + const triggerList = screen.getByText('Triggers').parentElement; + expect(triggerList).toHaveTextContent('✔️'); // Assuming 't1' is the only trigger + }); + }); + + describe('Status Updates (useStatusLogger)', () => { + test('Handles cond_norms_state_update', () => { + render(); + + // Initial state: activeIds empty. + act(() => { + statusUpdateCallback({ + type: 'cond_norms_state_update', + norms: [{ id: 'cn1', active: true }] + }); + }); + + // Conditional Norm 1 should now be active + const cnList = screen.getByText('Conditional Norms').parentElement; + expect(cnList).toHaveTextContent('✔️'); + }); + + test('Ignores status update if no changes detected', () => { + render(); + // First update + act(() => { + statusUpdateCallback({ type: 'cond_norms_state_update', norms: [{ id: 'cn1', active: true }] }); + }); + + // Second identical update - strictly checking if this causes a rerender is hard in RTL, + // but we ensure no errors and state remains consistent. + act(() => { + statusUpdateCallback({ type: 'cond_norms_state_update', norms: [{ id: 'cn1', active: true }] }); + }); + + const cnList = screen.getByText('Conditional Norms').parentElement; + expect(cnList).toHaveTextContent('✔️'); + }); + }); +}); + diff --git a/test/pages/monitoringPage/MonitoringPageAPI.test.ts b/test/pages/monitoringPage/MonitoringPageAPI.test.ts new file mode 100644 index 0000000..01a21b7 --- /dev/null +++ b/test/pages/monitoringPage/MonitoringPageAPI.test.ts @@ -0,0 +1,220 @@ +import { renderHook, act, cleanup } from '@testing-library/react'; +import { + sendAPICall, + nextPhase, + pauseExperiment, + playExperiment, + useExperimentLogger, + useStatusLogger +} from '../../../src/pages/MonitoringPage/MonitoringPageAPI'; + +// --- MOCK EVENT SOURCE SETUP --- +// This mocks the browser's EventSource so we can manually 'push' messages to our hooks +const mockInstances: MockEventSource[] = []; + +class MockEventSource { + url: string; + onmessage: ((event: MessageEvent) => void) | null = null; + onerror: ((event: Event) => void) | null = null; // Added onerror support + closed = false; + + constructor(url: string) { + this.url = url; + mockInstances.push(this); + } + + sendMessage(data: string) { + if (this.onmessage) { + this.onmessage({ data } as MessageEvent); + } + } + + triggerError(err: any) { + if (this.onerror) { + this.onerror(err); + } + } + + close() { + this.closed = true; + } +} + +// Mock global EventSource +beforeAll(() => { + (globalThis as any).EventSource = jest.fn((url: string) => new MockEventSource(url)); +}); + +// Mock global fetch +beforeEach(() => { + globalThis.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ reply: 'ok' }), + }) + ) as jest.Mock; +}); + +// Cleanup after every test +afterEach(() => { + cleanup(); + jest.restoreAllMocks(); + mockInstances.length = 0; +}); + +describe('MonitoringPageAPI', () => { + + describe('sendAPICall', () => { + test('sends correct POST request', async () => { + await sendAPICall('test_type', 'test_ctx'); + + expect(globalThis.fetch).toHaveBeenCalledWith( + 'http://localhost:8000/button_pressed', + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: 'test_type', context: 'test_ctx' }), + }) + ); + }); + + test('appends endpoint if provided', async () => { + await sendAPICall('t', 'c', '/extra'); + + expect(globalThis.fetch).toHaveBeenCalledWith( + expect.stringContaining('/button_pressed/extra'), + expect.any(Object) + ); + }); + + test('logs error on fetch network failure', async () => { + (globalThis.fetch as jest.Mock).mockRejectedValue('Network error'); + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + await sendAPICall('t', 'c'); + + expect(consoleSpy).toHaveBeenCalledWith('Failed to send api call:', 'Network error'); + }); + + test('throws error if response is not ok', async () => { + (globalThis.fetch as jest.Mock).mockResolvedValue({ ok: false }); + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + await sendAPICall('t', 'c'); + + expect(consoleSpy).toHaveBeenCalledWith('Failed to send api call:', expect.any(Error)); + }); + }); + + describe('Helper Functions', () => { + test('nextPhase sends correct params', async () => { + await nextPhase(); + expect(globalThis.fetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ body: JSON.stringify({ type: 'next_phase', context: '' }) }) + ); + }); + + test('pauseExperiment sends correct params', async () => { + await pauseExperiment(); + expect(globalThis.fetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ body: JSON.stringify({ type: 'pause', context: 'true' }) }) + ); + }); + + test('playExperiment sends correct params', async () => { + await playExperiment(); + expect(globalThis.fetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ body: JSON.stringify({ type: 'pause', context: 'false' }) }) + ); + }); + }); + + describe('useExperimentLogger', () => { + test('connects to SSE and receives messages', () => { + const onUpdate = jest.fn(); + + // Hook must be rendered to start the effect + renderHook(() => useExperimentLogger(onUpdate)); + + // Retrieve the mocked instance created by the hook + const eventSource = mockInstances[0]; + expect(eventSource.url).toContain('/experiment_stream'); + + // Simulate incoming message + act(() => { + eventSource.sendMessage(JSON.stringify({ type: 'phase_update', id: '1' })); + }); + + expect(onUpdate).toHaveBeenCalledWith({ type: 'phase_update', id: '1' }); + }); + + test('handles JSON parse errors in stream', () => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + renderHook(() => useExperimentLogger()); + const eventSource = mockInstances[0]; + + act(() => { + eventSource.sendMessage('invalid-json'); + }); + + expect(consoleSpy).toHaveBeenCalledWith('Stream parse error:', expect.any(Error)); + }); + + + + test('handles SSE connection error', () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + renderHook(() => useExperimentLogger()); + const eventSource = mockInstances[0]; + + act(() => { + eventSource.triggerError('Connection lost'); + }); + + expect(consoleSpy).toHaveBeenCalledWith('SSE Connection Error:', 'Connection lost'); + expect(eventSource.closed).toBe(true); + }); + + test('closes EventSource on unmount', () => { + const { unmount } = renderHook(() => useExperimentLogger()); + const eventSource = mockInstances[0]; + const closeSpy = jest.spyOn(eventSource, 'close'); + + unmount(); + + expect(closeSpy).toHaveBeenCalled(); + expect(eventSource.closed).toBe(true); + }); + }); + + describe('useStatusLogger', () => { + test('connects to SSE and receives messages', () => { + const onUpdate = jest.fn(); + renderHook(() => useStatusLogger(onUpdate)); + const eventSource = mockInstances[0]; + + expect(eventSource.url).toContain('/status_stream'); + + act(() => { + eventSource.sendMessage(JSON.stringify({ some: 'data' })); + }); + + expect(onUpdate).toHaveBeenCalledWith({ some: 'data' }); + }); + + test('handles JSON parse errors', () => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + renderHook(() => useStatusLogger()); + const eventSource = mockInstances[0]; + + act(() => { + eventSource.sendMessage('bad-data'); + }); + + expect(consoleSpy).toHaveBeenCalledWith('Status stream error:', expect.any(Error)); + }); + }); +}); \ No newline at end of file diff --git a/test/pages/monitoringPage/MonitoringPageComponents.test.tsx b/test/pages/monitoringPage/MonitoringPageComponents.test.tsx new file mode 100644 index 0000000..f454fe1 --- /dev/null +++ b/test/pages/monitoringPage/MonitoringPageComponents.test.tsx @@ -0,0 +1,226 @@ +import React from 'react'; +import { render, screen, fireEvent, act } from '@testing-library/react'; +import '@testing-library/jest-dom'; + +// Corrected Imports +import { + GestureControls, + SpeechPresets, + DirectSpeechInput, + StatusList, + RobotConnected +} from '../../../src/pages/MonitoringPage/MonitoringPageComponents'; + +import * as MonitoringAPI from '../../../src/pages/MonitoringPage/MonitoringPageAPI'; + +// Mock the API Call function with the correct path +jest.mock('../../../src/pages/MonitoringPage/MonitoringPageAPI', () => ({ + sendAPICall: jest.fn(), +})); + +describe('MonitoringPageComponents', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('GestureControls', () => { + test('renders and sends gesture command', () => { + render(); + + fireEvent.change(screen.getByRole('combobox'), { + target: { value: 'animations/Stand/Gestures/Hey_1' } + }); + + // Click button + fireEvent.click(screen.getByText('Actuate')); + + // Expect the API to be called with that new value + expect(MonitoringAPI.sendAPICall).toHaveBeenCalledWith('gesture', 'animations/Stand/Gestures/Hey_1'); + }); + }); + + describe('SpeechPresets', () => { + test('renders buttons and sends speech command', () => { + render(); + + const btn = screen.getByText('"Hello, I\'m Pepper"'); + fireEvent.click(btn); + + expect(MonitoringAPI.sendAPICall).toHaveBeenCalledWith('speech', "Hello, I'm Pepper"); + }); + }); + + describe('DirectSpeechInput', () => { + test('inputs text and sends on button click', () => { + render(); + const input = screen.getByPlaceholderText('Type message...'); + + fireEvent.change(input, { target: { value: 'Custom text' } }); + fireEvent.click(screen.getByText('Send')); + + expect(MonitoringAPI.sendAPICall).toHaveBeenCalledWith('speech', 'Custom text'); + expect(input).toHaveValue(''); // Should clear + }); + + test('sends on Enter key', () => { + render(); + const input = screen.getByPlaceholderText('Type message...'); + + fireEvent.change(input, { target: { value: 'Enter text' } }); + fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' }); + + expect(MonitoringAPI.sendAPICall).toHaveBeenCalledWith('speech', 'Enter text'); + }); + + test('does not send empty text', () => { + render(); + fireEvent.click(screen.getByText('Send')); + expect(MonitoringAPI.sendAPICall).not.toHaveBeenCalled(); + }); + }); + + describe('StatusList', () => { + const mockSet = jest.fn(); + const items = [ + { id: '1', name: 'Item 1' }, + { id: '2', name: 'Item 2' } + ]; + + test('renders list items', () => { + render(); + expect(screen.getByText('Test List')).toBeInTheDocument(); + expect(screen.getByText('Item 1')).toBeInTheDocument(); + }); + + test('Goals: click override on inactive item calls API', () => { + render( + + ); + + // Click the X (inactive) + const indicator = screen.getAllByText('❌')[0]; + fireEvent.click(indicator); + + expect(MonitoringAPI.sendAPICall).toHaveBeenCalledWith('override', '1'); + expect(mockSet).toHaveBeenCalled(); + }); + + test('Conditional Norms: click override on ACTIVE item unachieves', () => { + render( + + ); + + const indicator = screen.getByText('✔️'); // It is active + fireEvent.click(indicator); + + expect(MonitoringAPI.sendAPICall).toHaveBeenCalledWith('override_unachieve', '1'); + }); + + test('Current Goal highlighting', () => { + render( + + ); + // Using regex to handle the "(Current)" text + expect(screen.getByText(/Item 1/)).toBeInTheDocument(); + expect(screen.getByText(/(Current)/)).toBeInTheDocument(); + }); + }); + + describe('RobotConnected', () => { + let mockEventSource: any; + + beforeAll(() => { + Object.defineProperty(window, 'EventSource', { + writable: true, + value: jest.fn().mockImplementation(() => ({ + close: jest.fn(), + onmessage: null, + })), + }); + }); + + beforeEach(() => { + mockEventSource = new window.EventSource('url'); + (window.EventSource as unknown as jest.Mock).mockClear(); + (window.EventSource as unknown as jest.Mock).mockImplementation(() => mockEventSource); + }); + + test('displays disconnected initially', () => { + render(); + expect(screen.getByText('● Robot is disconnected')).toBeInTheDocument(); + }); + + test('updates to connected when SSE receives true', async () => { + render(); + + act(() => { + if(mockEventSource.onmessage) { + mockEventSource.onmessage({ data: 'true' } as MessageEvent); + } + }); + + expect(await screen.findByText('● Robot is connected')).toBeInTheDocument(); + }); + + test('handles invalid JSON gracefully', async () => { + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + render(); + + act(() => { + if(mockEventSource.onmessage) { + mockEventSource.onmessage({ data: 'invalid-json' } as MessageEvent); + } + }); + + // Should catch error and log it, state remains disconnected + expect(consoleSpy).toHaveBeenCalledWith('Ping message not in correct format:', 'invalid-json'); + consoleSpy.mockRestore(); + }); + + test('logs error if state update fails (inner catch block)', async () => { + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + + // 1. Force useState to return a setter that throws an error + const mockThrowingSetter = jest.fn(() => { throw new Error('Forced State Error'); }); + + // We use mockImplementation to return [currentState, throwingSetter] + const useStateSpy = jest.spyOn(React, 'useState') + .mockImplementation(() => [null, mockThrowingSetter]); + + render(); + + // 2. Trigger the event with VALID JSON ("true") + // This passes the first JSON.parse try/catch, + // but fails when calling setConnected(true) because of our mock. + await act(async () => { + if (mockEventSource.onmessage) { + mockEventSource.onmessage({ data: 'true' } as MessageEvent); + } + }); + + // 3. Verify the specific error log from line 205 + expect(consoleSpy).toHaveBeenCalledWith("couldnt extract connected from incoming ping data"); + + // Cleanup spies + useStateSpy.mockRestore(); + consoleSpy.mockRestore(); + }); + }); +}); diff --git a/test/pages/simpleProgram/SimpleProgram.tsx b/test/pages/simpleProgram/SimpleProgram.tsx new file mode 100644 index 0000000..22fcbbf --- /dev/null +++ b/test/pages/simpleProgram/SimpleProgram.tsx @@ -0,0 +1,83 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import SimpleProgram from "../../../src/pages/SimpleProgram/SimpleProgram"; +import useProgramStore from "../../../src/utils/programStore"; + +/** + * Helper to preload the program store before rendering. + */ +function loadProgram(phases: Record[]) { + useProgramStore.getState().setProgramState({ phases }); +} + +describe("SimpleProgram", () => { + beforeEach(() => { + loadProgram([]); + }); + + test("shows empty state when no program is loaded", () => { + render(); + expect(screen.getByText("No program loaded.")).toBeInTheDocument(); + }); + + test("renders first phase content", () => { + loadProgram([ + { + id: "phase-1", + norms: [{ id: "n1", norm: "Be polite" }], + goals: [{ id: "g1", description: "Finish task", achieved: true }], + triggers: [{ id: "t1", label: "Keyword trigger" }], + }, + ]); + + render(); + + expect(screen.getByText("Phase 1 / 1")).toBeInTheDocument(); + expect(screen.getByText("Be polite")).toBeInTheDocument(); + expect(screen.getByText("Finish task")).toBeInTheDocument(); + expect(screen.getByText("Keyword trigger")).toBeInTheDocument(); + }); + + test("allows navigating between phases", () => { + loadProgram([ + { + id: "phase-1", + norms: [], + goals: [], + triggers: [], + }, + { + id: "phase-2", + norms: [{ id: "n2", norm: "Be careful" }], + goals: [], + triggers: [], + }, + ]); + + render(); + + expect(screen.getByText("Phase 1 / 2")).toBeInTheDocument(); + + fireEvent.click(screen.getByText("Next ▶")); + + expect(screen.getByText("Phase 2 / 2")).toBeInTheDocument(); + expect(screen.getByText("Be careful")).toBeInTheDocument(); + }); + + test("prev button is disabled on first phase", () => { + loadProgram([ + { id: "phase-1", norms: [], goals: [], triggers: [] }, + ]); + + render(); + expect(screen.getByText("◀ Prev")).toBeDisabled(); + }); + + test("next button is disabled on last phase", () => { + loadProgram([ + { id: "phase-1", norms: [], goals: [], triggers: [] }, + ]); + + render(); + expect(screen.getByText("Next ▶")).toBeDisabled(); + }); +}); diff --git a/test/pages/visProgPage/visualProgrammingUI/EditorUndoRedo.test.ts b/test/pages/visProgPage/visualProgrammingUI/EditorUndoRedo.test.ts index f7233d8..39b459d 100644 --- a/test/pages/visProgPage/visualProgrammingUI/EditorUndoRedo.test.ts +++ b/test/pages/visProgPage/visualProgrammingUI/EditorUndoRedo.test.ts @@ -34,10 +34,17 @@ describe("UndoRedo Middleware", () => { type: 'default', position: {x: 0, y: 0}, data: {label: 'A'} - }, + } ], - edges: [] + edges: [], + warnings: { + warningRegistry: new Map(), + severityIndex: new Map() + } }], + ruleRegistry: new Map(), + editorWarningRegistry: new Map(), + severityIndex: new Map() }); act(() => { @@ -53,7 +60,11 @@ describe("UndoRedo Middleware", () => { position: {x: 0, y: 0}, data: {label: 'A'} }], - edges: [] + edges: [], + warnings: { + warningRegistry: {}, + severityIndex: {} + } }); expect(state.future).toEqual([]); }); @@ -80,7 +91,9 @@ describe("UndoRedo Middleware", () => { position: {x: 0, y: 0}, data: {label: 'A'} }], - edges: [] + edges: [], + editorWarningRegistry: new Map(), + severityIndex: new Map() }); act(() => { @@ -114,7 +127,11 @@ describe("UndoRedo Middleware", () => { position: {x: 0, y: 0}, data: {label: 'B'} }], - edges: [] + edges: [], + warnings: { + warningRegistry: {}, + severityIndex: {} + } }); }); @@ -140,7 +157,9 @@ describe("UndoRedo Middleware", () => { position: {x: 0, y: 0}, data: {label: 'A'} }], - edges: [] + edges: [], + editorWarningRegistry: new Map(), + severityIndex: new Map() }); act(() => { @@ -176,7 +195,11 @@ describe("UndoRedo Middleware", () => { position: {x: 0, y: 0}, data: {label: 'A'} }], - edges: [] + edges: [], + warnings: { + warningRegistry: {}, + severityIndex: {} + } }); }); @@ -199,7 +222,9 @@ describe("UndoRedo Middleware", () => { position: {x: 0, y: 0}, data: {label: 'A'} }], - edges: [] + edges: [], + editorWarningRegistry: new Map(), + severityIndex: new Map() }); act(() => { store.getState().beginBatchAction(); }); diff --git a/test/pages/visProgPage/visualProgrammingUI/VisProgStores.test.tsx b/test/pages/visProgPage/visualProgrammingUI/VisProgStores.test.tsx index fa98048..d53d1bc 100644 --- a/test/pages/visProgPage/visualProgrammingUI/VisProgStores.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/VisProgStores.test.tsx @@ -1,5 +1,9 @@ import {act} from '@testing-library/react'; -import type {Connection, Edge, Node} from "@xyflow/react"; +import { + type Connection, + type Edge, + type Node, +} from "@xyflow/react"; import type {HandleRule, RuleResult} from "../../../../src/pages/VisProgPage/visualProgrammingUI/HandleRuleLogic.ts"; import { NodeDisconnections } from "../../../../src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts"; import type {PhaseNodeData} from "../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx"; @@ -398,6 +402,7 @@ describe('FlowStore Functionality', () => { }] }); + act(()=> { deleteNode(nodeId); }); diff --git a/test/pages/visProgPage/visualProgrammingUI/components/EditorWarnings.test.tsx b/test/pages/visProgPage/visualProgrammingUI/components/EditorWarnings.test.tsx new file mode 100644 index 0000000..8351c8d --- /dev/null +++ b/test/pages/visProgPage/visualProgrammingUI/components/EditorWarnings.test.tsx @@ -0,0 +1,152 @@ +import { describe, it, expect} from '@jest/globals'; +import { + type EditorWarning, warningSummary +} from "../../../../../src/pages/VisProgPage/visualProgrammingUI/components/EditorWarnings.tsx"; +import useFlowStore from "../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx"; + + +function makeWarning( + overrides?: Partial +): EditorWarning { + return { + scope: { id: 'node-1' }, + type: 'MISSING_INPUT', + severity: 'ERROR', + description: 'Missing input', + ...overrides, + }; +} + +describe("editorWarnings", () => { + describe('registerWarning', () => { + it('registers a node-level warning', () => { + const warning = makeWarning(); + const {registerWarning, getWarnings} = useFlowStore.getState() + registerWarning(warning); + + const warnings = getWarnings(); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toEqual(warning); + }); + + it('registers a handle-level warning with scoped key', () => { + const warning = makeWarning({ + scope: { id: 'node-1', handleId: 'input-1' }, + }); + const {registerWarning} = useFlowStore.getState() + registerWarning(warning); + const nodeWarnings = useFlowStore.getState().editorWarningRegistry.get('node-1'); + expect(nodeWarnings?.has('MISSING_INPUT:input-1') === true).toBe(true); + }); + + it('updates severityIndex correctly', () => { + const {registerWarning, severityIndex} = useFlowStore.getState() + registerWarning(makeWarning()); + expect(severityIndex.get('ERROR')!.size).toBe(1); + }); + }); + + describe('getWarningsBySeverity', () => { + it('returns only warnings of requested severity', () => { + const {registerWarning, getWarningsBySeverity} = useFlowStore.getState() + registerWarning( + makeWarning({ severity: 'ERROR' }) + ); + + registerWarning( + makeWarning({ + severity: 'WARNING', + type: 'MISSING_OUTPUT', + }) + ); + + const errors = getWarningsBySeverity('ERROR'); + const warnings = getWarningsBySeverity('WARNING'); + + expect(errors).toHaveLength(1); + expect(warnings).toHaveLength(1); + }); + }); + + describe('isProgramValid', () => { + it('returns true when no ERROR warnings exist', () => { + expect(useFlowStore.getState().isProgramValid()).toBe(true); + }); + + it('returns false when ERROR warnings exist', () => { + const {registerWarning, isProgramValid} = useFlowStore.getState() + registerWarning(makeWarning()); + expect(isProgramValid()).toBe(false); + }); + }); + + describe('unregisterWarning', () => { + it('removes warning from registry and severityIndex', () => { + const warning = makeWarning(); + const { + registerWarning, + getWarnings, + unregisterWarning, + severityIndex + } = useFlowStore.getState() + + registerWarning(warning); + + unregisterWarning('node-1', 'MISSING_INPUT'); + + expect(getWarnings()).toHaveLength(0); + expect(severityIndex.get('ERROR')!.size).toBe(0); + }); + + it('does nothing if warning does not exist', () => { + expect(() => + useFlowStore.getState().unregisterWarning('node-1', 'DOES_NOT_EXIST') + ).not.toThrow(); + }); + }); + + describe('unregisterWarningsForId', () => { + it('removes all warnings for a node', () => { + const {registerWarning, unregisterWarningsForId, getWarnings, severityIndex} = useFlowStore.getState() + registerWarning( + makeWarning({ + scope: { id: 'node-1', handleId: 'h1' }, + }) + ); + + registerWarning( + makeWarning({ + scope: { id: 'node-1' }, + type: 'MISSING_OUTPUT', + severity: 'WARNING', + }) + ); + + unregisterWarningsForId('node-1'); + + expect(getWarnings()).toHaveLength(0); + expect( + severityIndex.get('ERROR')!.size + ).toBe(0); + expect( + severityIndex.get('WARNING')!.size + ).toBe(0); + }); + }); + + describe('warningSummary', () => { + it('returns correct counts and validity', () => { + const {registerWarning} = useFlowStore.getState() + registerWarning( + makeWarning({ severity: 'ERROR' }) + ); + + const summary = warningSummary(); + + expect(summary.error).toBe(1); + expect(summary.warning).toBe(0); + expect(summary.info).toBe(0); + expect(summary.isValid).toBe(false); + }); + }); +}) diff --git a/test/pages/visProgPage/visualProgrammingUI/components/SaveLoadPanel.test.tsx b/test/pages/visProgPage/visualProgrammingUI/components/SaveLoadPanel.test.tsx index 9d85323..65458c9 100644 --- a/test/pages/visProgPage/visualProgrammingUI/components/SaveLoadPanel.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/components/SaveLoadPanel.test.tsx @@ -105,6 +105,8 @@ describe("SaveLoadPanel - combined tests", () => { }); test("onLoad with invalid JSON does not update store", async () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + const file = new File(["not json"], "bad.json", { type: "application/json" }); file.text = jest.fn(() => Promise.resolve(`{"bad json`)); @@ -112,20 +114,19 @@ describe("SaveLoadPanel - combined tests", () => { render(); const input = document.querySelector('input[type="file"]') as HTMLInputElement; - expect(input).toBeTruthy(); - - // Give some input + act(() => { fireEvent.change(input, { target: { files: [file] } }); }); await waitFor(() => { expect(window.alert).toHaveBeenCalledTimes(1); - const nodesAfter = useFlowStore.getState().nodes; expect(nodesAfter).toHaveLength(0); - expect(input.value).toBe(""); }); + + // Clean up the spy + consoleSpy.mockRestore(); }); test("onLoad resolves to null when no file is chosen (user cancels) and does not update store", async () => { diff --git a/test/pages/visProgPage/visualProgrammingUI/components/WarningSidebar.test.tsx b/test/pages/visProgPage/visualProgrammingUI/components/WarningSidebar.test.tsx new file mode 100644 index 0000000..9ccf735 --- /dev/null +++ b/test/pages/visProgPage/visualProgrammingUI/components/WarningSidebar.test.tsx @@ -0,0 +1,138 @@ +import {fireEvent, render, screen} from '@testing-library/react'; +import '@testing-library/jest-dom'; +import {useReactFlow, useStoreApi} from "@xyflow/react"; +import { + type EditorWarning, + globalWarning +} from "../../../../../src/pages/VisProgPage/visualProgrammingUI/components/EditorWarnings.tsx"; +import {WarningsSidebar} from "../../../../../src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.tsx"; +import useFlowStore from "../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx"; + + +jest.mock('@xyflow/react', () => ({ + useReactFlow: jest.fn(), + useStoreApi: jest.fn(), +})); + +jest.mock('../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx'); + +function makeWarning( + overrides?: Partial +): EditorWarning { + return { + scope: { id: 'node-1' }, + type: 'MISSING_INPUT', + severity: 'ERROR', + description: 'Missing input', + ...overrides, + }; +} + +describe('WarningsSidebar', () => { + let getStateSpy: jest.SpyInstance; + + const setCenter = jest.fn(() => Promise.resolve()); + const getNode = jest.fn(); + const addSelectedNodes = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + + // React Flow hooks + (useReactFlow as jest.Mock).mockReturnValue({ + getNode, + setCenter, + }); + (useStoreApi as jest.Mock).mockReturnValue({ + getState: () => ({ addSelectedNodes }), + }); + + // Use spyOn to override store + const mockWarnings = [ + makeWarning({ description: 'Node warning', scope: { id: 'node-1' } }), + makeWarning({ + description: 'Global warning', + scope: { id: globalWarning }, + type: 'INCOMPLETE_PROGRAM', + severity: 'WARNING', + }), + makeWarning({ + description: 'Info warning', + scope: { id: 'node-2' }, + severity: 'INFO', + }), + ]; + + getStateSpy = jest + .spyOn(useFlowStore, 'getState') + .mockReturnValue({ + getWarnings: () => mockWarnings, + } as any); + }); + + afterEach(() => { + getStateSpy.mockRestore(); + }); + + it('renders warnings header', () => { + render(); + expect(screen.getByText('Warnings')).toBeInTheDocument(); + }); + + it('renders all warning descriptions', () => { + render(); + expect(screen.getByText('Node warning')).toBeInTheDocument(); + expect(screen.getByText('Global warning')).toBeInTheDocument(); + expect(screen.getByText('Info warning')).toBeInTheDocument(); + }); + + it('splits global and other warnings correctly', () => { + render(); + expect(screen.getByText('global:')).toBeInTheDocument(); + expect(screen.getByText('other:')).toBeInTheDocument(); + }); + + it('shows empty state when no warnings exist', () => { + getStateSpy.mockReturnValueOnce({ + getWarnings: () => [], + } as any); + + render(); + expect(screen.getByText('No warnings!')).toBeInTheDocument(); + }); + + it('filters by severity', () => { + render(); + fireEvent.click(screen.getByText('ERROR')); + + expect(screen.getByText('Node warning')).toBeInTheDocument(); + expect(screen.queryByText('Global warning')).not.toBeInTheDocument(); + expect(screen.queryByText('Info warning')).not.toBeInTheDocument(); + }); + + it('filters INFO severity correctly', () => { + render(); + fireEvent.click(screen.getByText('INFO')); + + expect(screen.getByText('Info warning')).toBeInTheDocument(); + expect(screen.queryByText('Node warning')).not.toBeInTheDocument(); + expect(screen.queryByText('Global warning')).not.toBeInTheDocument(); + }); + + it('clicking global warning does NOT jump', () => { + render(); + fireEvent.click(screen.getByText('Global warning')); + + expect(setCenter).not.toHaveBeenCalled(); + expect(addSelectedNodes).not.toHaveBeenCalled(); + }); + + it('does nothing if node does not exist', () => { + getNode.mockReturnValue(undefined); + + render(); + fireEvent.click(screen.getByText('Node warning')); + + expect(setCenter).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/BeliefNode.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/BeliefNode.test.tsx index a023769..67d80c6 100644 --- a/test/pages/visProgPage/visualProgrammingUI/nodes/BeliefNode.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/BeliefNode.test.tsx @@ -2,7 +2,7 @@ import { describe, it, beforeEach } from '@jest/globals'; import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { renderWithProviders } from '../.././/./../../test-utils/test-utils'; +import { renderWithProviders } from '../../../../test-utils/test-utils.tsx'; import BasicBeliefNode, { type BasicBeliefNodeData } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx'; import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores'; import type { Node } from '@xyflow/react'; @@ -150,7 +150,7 @@ describe('BasicBeliefNode', () => { expect(screen.getByDisplayValue('Emotion recognised:')).toBeInTheDocument(); // For emotion type, we should check that the select has the correct value selected - const selectElement = screen.getByDisplayValue('Happy'); + const selectElement = screen.getByDisplayValue('happy'); expect(selectElement).toBeInTheDocument(); expect((selectElement as HTMLSelectElement).value).toBe('happy'); }); @@ -185,14 +185,14 @@ describe('BasicBeliefNode', () => { /> ); - const selectElement = screen.getByDisplayValue('Happy'); + const selectElement = screen.getByDisplayValue('happy'); expect(selectElement).toBeInTheDocument(); // Check that all emotion options are present - expect(screen.getByText('Happy')).toBeInTheDocument(); - expect(screen.getByText('Angry')).toBeInTheDocument(); - expect(screen.getByText('Sad')).toBeInTheDocument(); - expect(screen.getByText('Cheerful')).toBeInTheDocument(); + expect(screen.getByText('happy')).toBeInTheDocument(); + expect(screen.getByText('angry')).toBeInTheDocument(); + expect(screen.getByText('sad')).toBeInTheDocument(); + expect(screen.getByText('surprise')).toBeInTheDocument(); }); it('should render without wrapping quotes for object type', () => { @@ -382,7 +382,7 @@ describe('BasicBeliefNode', () => { data: { label: 'Belief', droppable: true, - belief: { type: 'emotion', id: 'em1', value: 'happy', label: 'Emotion recognised:' }, + belief: { type: 'emotion', id: 'em1', value: 'sad', label: 'Emotion recognised:' }, hasReduce: true, }, }; @@ -409,13 +409,13 @@ describe('BasicBeliefNode', () => { /> ); - const select = screen.getByDisplayValue('Happy'); - await user.selectOptions(select, 'sad'); + const select = screen.getByDisplayValue('sad'); + await user.selectOptions(select, 'happy'); await waitFor(() => { const state = useFlowStore.getState(); const updatedNode = state.nodes.find(n => n.id === 'belief-1') as Node; - expect(updatedNode?.data.belief.value).toBe('sad'); + expect(updatedNode?.data.belief.value).toBe('happy'); }); }); @@ -511,13 +511,11 @@ describe('BasicBeliefNode', () => { expect(updatedNode?.data.belief.type).toBe('emotion'); // The component doesn't reset the value when changing types // So it keeps the old value even though it doesn't make sense for emotion type - expect(updatedNode?.data.belief.value).toBe('Happy'); + expect(updatedNode?.data.belief.value).toBe('sad'); }); }); }); - // ... rest of the tests remain the same, just fixing the Integration with Store section ... - describe('Integration with Store', () => { it('should properly update the store when changing belief value', async () => { const mockNode: Node = { diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/TriggerNode.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/TriggerNode.test.tsx index 83bcf34..43530a2 100644 --- a/test/pages/visProgPage/visualProgrammingUI/nodes/TriggerNode.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/TriggerNode.test.tsx @@ -14,7 +14,7 @@ import { BasicBeliefNodeDefaults } from '../../../../../src/pages/VisProgPage/vi import { defaultPlan } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/components/Plan.default.ts'; import { NormNodeDefaults } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.default.ts'; import { GoalNodeDefaults } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.default.ts'; -import { act } from 'react-dom/test-utils'; +import { act } from '@testing-library/react'; describe('TriggerNode', () => { @@ -137,7 +137,6 @@ describe('TriggerNode', () => { }); }); - describe('TriggerConnects Function', () => { it('should correctly remove a goal from the triggers plan after it has been disconnected', () => { // first, define the goal node and trigger node. @@ -162,7 +161,6 @@ describe('TriggerNode', () => { act(() => { useFlowStore.getState().onConnect({ source: 'g-1', target: 'trigger-1', sourceHandle: null, targetHandle: null }); }); - // expect the goal id to be part of a goal step of the plan. let updatedTrigger = useFlowStore.getState().nodes.find((n) => n.id === 'trigger-1'); expect(updatedTrigger?.data.plan).toBeDefined(); @@ -181,4 +179,4 @@ describe('TriggerNode', () => { expect(stillHas).toBeUndefined(); }); }); -}); + }); diff --git a/test/setupFlowTests.ts b/test/setupFlowTests.ts index e3382c6..caeda94 100644 --- a/test/setupFlowTests.ts +++ b/test/setupFlowTests.ts @@ -1,5 +1,9 @@ import '@testing-library/jest-dom'; import { cleanup } from '@testing-library/react'; +import { + type CompositeWarningKey, + type SeverityIndex, +} from "../src/pages/VisProgPage/visualProgrammingUI/components/EditorWarnings.tsx"; import useFlowStore from '../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx'; if (!globalThis.structuredClone) { @@ -69,8 +73,6 @@ export const mockReactFlow = () => { }; - - beforeAll(() => { useFlowStore.setState({ nodes: [], @@ -79,7 +81,13 @@ beforeAll(() => { future: [], isBatchAction: false, edgeReconnectSuccessful: true, - ruleRegistry: new Map() + ruleRegistry: new Map(), + editorWarningRegistry: new Map(), + severityIndex: new Map([ + ['INFO', new Set()], + ['WARNING', new Set()], + ['ERROR', new Set()], + ]) as SeverityIndex, }); }); @@ -92,7 +100,13 @@ afterEach(() => { future: [], isBatchAction: false, edgeReconnectSuccessful: true, - ruleRegistry: new Map() + ruleRegistry: new Map(), + editorWarningRegistry: new Map(), + severityIndex: new Map([ + ['INFO', new Set()], + ['WARNING', new Set()], + ['ERROR', new Set()], + ]) as SeverityIndex, }); }); diff --git a/test/test-utils/test-utils.tsx b/test/test-utils/test-utils.tsx index a39e01a..157ea19 100644 --- a/test/test-utils/test-utils.tsx +++ b/test/test-utils/test-utils.tsx @@ -2,6 +2,9 @@ import { render, type RenderOptions } from '@testing-library/react'; import { type ReactElement, type ReactNode } from 'react'; import { ReactFlowProvider } from '@xyflow/react'; +import {mockReactFlow} from "../setupFlowTests.ts"; + +mockReactFlow(); /** * Custom render function that wraps components with necessary providers diff --git a/test/utils/programStore.test.ts b/test/utils/programStore.test.ts index ba78b88..9865668 100644 --- a/test/utils/programStore.test.ts +++ b/test/utils/programStore.test.ts @@ -113,4 +113,114 @@ describe('useProgramStore', () => { // store should NOT change expect(storedProgram.phases[0]['norms']).toHaveLength(1); }); -}); \ No newline at end of file +}); + +describe('getGoalsWithDepth', () => { + const complexProgram: ReducedProgram = { + phases: [ + { + id: 'phase-nested', + goals: [ + // Level 0: Root Goal 1 + { + id: 'root-1', + name: 'Root Goal 1', + plan: { + steps: [ + // This is an ACTION (no plan), should be ignored + { id: 'action-1', type: 'speech' }, + + // Level 1: Child Goal + { + id: 'child-1', + name: 'Child Goal', + plan: { + steps: [ + // Level 2: Grandchild Goal + { + id: 'grandchild-1', + name: 'Grandchild', + plan: { steps: [] } // Empty plan is still a plan + } + ] + } + } + ] + } + }, + // Level 0: Root Goal 2 (Sibling) + { + id: 'root-2', + name: 'Root Goal 2', + plan: { steps: [] } + } + ] + } + ] + }; + + it('should flatten nested goals and assign correct depth levels', () => { + useProgramStore.getState().setProgramState(complexProgram); + + const goals = useProgramStore.getState().getGoalsWithDepth('phase-nested'); + + // logic: Root 1 -> Child 1 -> Grandchild 1 -> Root 2 + expect(goals).toHaveLength(4); + + // Check Root 1 + expect(goals[0]).toEqual(expect.objectContaining({ id: 'root-1', level: 0 })); + + // Check Child 1 + expect(goals[1]).toEqual(expect.objectContaining({ id: 'child-1', level: 1 })); + + // Check Grandchild 1 + expect(goals[2]).toEqual(expect.objectContaining({ id: 'grandchild-1', level: 2 })); + + // Check Root 2 + expect(goals[3]).toEqual(expect.objectContaining({ id: 'root-2', level: 0 })); + }); + + it('should ignore steps that are not goals (missing "plan" property)', () => { + useProgramStore.getState().setProgramState(complexProgram); + const goals = useProgramStore.getState().getGoalsWithDepth('phase-nested'); + + // The 'action-1' object should NOT be in the list + const action = goals.find(g => g.id === 'action-1'); + expect(action).toBeUndefined(); + }); + + it('throws if phase does not exist', () => { + useProgramStore.getState().setProgramState(complexProgram); + + expect(() => + useProgramStore.getState().getGoalsWithDepth('missing-phase') + ).toThrow('phase with id:"missing-phase" not found'); + }); + }); + +it('should return the names of all phases in the program', () => { + // Define a program specifically with names for this test + const programWithNames: ReducedProgram = { + phases: [ + { + id: 'phase-1', + name: 'Introduction Phase', // Assuming the property is 'name' + norms: [], + goals: [], + triggers: [], + }, + { + id: 'phase-2', + name: 'Execution Phase', + norms: [], + goals: [], + triggers: [], + }, + ], + }; + + useProgramStore.getState().setProgramState(programWithNames); + + const phaseNames = useProgramStore.getState().getPhaseNames(); + expect(phaseNames).toEqual(['Introduction Phase', 'Execution Phase']); + }); \ No newline at end of file -- 2.49.1 From 835de03a29beab26d01393a26e504941d2931a86 Mon Sep 17 00:00:00 2001 From: Twirre Date: Wed, 28 Jan 2026 10:15:58 +0000 Subject: [PATCH 183/184] Add experiment logs to the monitoring page --- src/App.css | 38 +++- src/App.tsx | 2 +- src/components/Dialog.tsx | 48 +++++ src/components/Icons/Next.tsx | 5 + src/components/Icons/Pause.tsx | 5 + src/components/Icons/Play.tsx | 5 + src/components/Icons/Redo.tsx | 5 + src/components/Icons/Replay.tsx | 5 + src/components/Logging/Definitions.ts | 31 +++ src/components/Logging/Filters.tsx | 5 +- src/components/Logging/Logging.module.css | 5 +- src/components/Logging/Logging.tsx | 101 +++++----- src/components/Logging/useLogs.ts | 23 ++- src/index.css | 5 +- .../MonitoringPage/MonitoringPage.module.css | 42 ++-- src/pages/MonitoringPage/MonitoringPage.tsx | 16 +- .../components/ExperimentLogs.module.css | 34 ++++ .../components/ExperimentLogs.tsx | 186 ++++++++++++++++++ src/utils/capitalize.ts | 3 + src/utils/delayedResolve.ts | 7 + src/utils/priorityFiltering.ts | 2 +- test/components/Logging/Logging.test.tsx | 13 +- test/setupTests.ts | 46 ++++- test/utils/capitalize.test.ts | 34 ++++ test/utils/delayedResolve.test.ts | 77 ++++++++ 25 files changed, 619 insertions(+), 124 deletions(-) create mode 100644 src/components/Dialog.tsx create mode 100644 src/components/Icons/Next.tsx create mode 100644 src/components/Icons/Pause.tsx create mode 100644 src/components/Icons/Play.tsx create mode 100644 src/components/Icons/Redo.tsx create mode 100644 src/components/Icons/Replay.tsx create mode 100644 src/components/Logging/Definitions.ts create mode 100644 src/pages/MonitoringPage/components/ExperimentLogs.module.css create mode 100644 src/pages/MonitoringPage/components/ExperimentLogs.tsx create mode 100644 src/utils/capitalize.ts create mode 100644 src/utils/delayedResolve.ts create mode 100644 test/utils/capitalize.test.ts create mode 100644 test/utils/delayedResolve.test.ts diff --git a/src/App.css b/src/App.css index 8e078f6..e0c9542 100644 --- a/src/App.css +++ b/src/App.css @@ -161,7 +161,13 @@ input[type="checkbox"] { .margin-0 { margin: 0; } +.margin-lg { + margin: 1rem; +} +.padding-0 { + padding: 0; +} .padding-sm { padding: .25rem; } @@ -171,11 +177,9 @@ input[type="checkbox"] { .padding-lg { padding: 1rem; } -.padding-b-sm { - padding-bottom: .25rem; -} -.padding-b-md { - padding-bottom: .5rem; +.padding-h-lg { + padding-left: 1rem; + padding-right: 1rem; } .padding-b-lg { padding-bottom: 1rem; @@ -204,6 +208,27 @@ input[type="checkbox"] { border: 3px solid canvastext; } +.shadow-sm { + box-shadow: 0 0 0.25rem rgba(0, 0, 0, 0.25); +} +.shadow-md { + box-shadow: 0 0 0.5rem rgba(0, 0, 0, 0.25); +} +.shadow-lg { + box-shadow: 0 0 1rem rgba(0, 0, 0, 0.25); +} +@media (prefers-color-scheme: dark) { + .shadow-sm { + box-shadow: 0 0 0.25rem rgba(0, 0, 0, 0.5); + } + .shadow-md { + box-shadow: 0 0 0.5rem rgba(0, 0, 0, 0.5); + } + .shadow-lg { + box-shadow: 0 0 1rem rgba(0, 0, 0, 0.5); + } +} + .font-small { font-size: .75rem; } @@ -220,6 +245,9 @@ input[type="checkbox"] { font-weight: bold; } +.relative { + position: relative; +} .clickable { cursor: pointer; diff --git a/src/App.tsx b/src/App.tsx index e0576a2..8e09ba6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,7 +16,7 @@ function App(){ <>
    Home - +
    diff --git a/src/components/Dialog.tsx b/src/components/Dialog.tsx new file mode 100644 index 0000000..093f0a4 --- /dev/null +++ b/src/components/Dialog.tsx @@ -0,0 +1,48 @@ +import {type ReactNode, type RefObject, useEffect, useRef} from "react"; + +export default function Dialog({ + open, + close, + classname, + children, +}: { + open: boolean; + close: () => void; + classname?: string; + children: ReactNode; +}) { + const ref: RefObject = useRef(null); + + useEffect(() => { + if (open) { + ref.current?.showModal(); + } else { + ref.current?.close(); + } + }, [open]); + + function handleClickOutside(event: React.MouseEvent) { + if (!ref.current) return; + + const dialogDimensions = ref.current.getBoundingClientRect() + if ( + event.clientX < dialogDimensions.left || + event.clientX > dialogDimensions.right || + event.clientY < dialogDimensions.top || + event.clientY > dialogDimensions.bottom + ) { + close(); + } + } + + return ( + + {children} + + ); +} diff --git a/src/components/Icons/Next.tsx b/src/components/Icons/Next.tsx new file mode 100644 index 0000000..1d30937 --- /dev/null +++ b/src/components/Icons/Next.tsx @@ -0,0 +1,5 @@ +export default function Next({ fill }: { fill?: string }) { + return + + ; +} \ No newline at end of file diff --git a/src/components/Icons/Pause.tsx b/src/components/Icons/Pause.tsx new file mode 100644 index 0000000..dcf5e08 --- /dev/null +++ b/src/components/Icons/Pause.tsx @@ -0,0 +1,5 @@ +export default function Pause({ fill }: { fill?: string }) { + return + + ; +} diff --git a/src/components/Icons/Play.tsx b/src/components/Icons/Play.tsx new file mode 100644 index 0000000..3f0fb6b --- /dev/null +++ b/src/components/Icons/Play.tsx @@ -0,0 +1,5 @@ +export default function Play({ fill }: { fill?: string }) { + return + + ; +} diff --git a/src/components/Icons/Redo.tsx b/src/components/Icons/Redo.tsx new file mode 100644 index 0000000..4268fc5 --- /dev/null +++ b/src/components/Icons/Redo.tsx @@ -0,0 +1,5 @@ +export default function Redo({ fill }: { fill?: string }) { + return + + ; +} \ No newline at end of file diff --git a/src/components/Icons/Replay.tsx b/src/components/Icons/Replay.tsx new file mode 100644 index 0000000..057d4b4 --- /dev/null +++ b/src/components/Icons/Replay.tsx @@ -0,0 +1,5 @@ +export default function Replay({ fill }: { fill?: string }) { + return + + ; +} diff --git a/src/components/Logging/Definitions.ts b/src/components/Logging/Definitions.ts new file mode 100644 index 0000000..a870301 --- /dev/null +++ b/src/components/Logging/Definitions.ts @@ -0,0 +1,31 @@ +import type {Cell} from "../../utils/cellStore.ts"; +import type {LogRecord} from "./useLogs.ts"; + +/** + * Zustand store definition for managing user preferences related to logging. + * + * Includes flags for toggling relative timestamps and automatic scroll behavior. + */ +export type LoggingSettings = { + /** Whether to display log timestamps as relative (e.g., "2m 15s ago") instead of absolute. */ + showRelativeTime: boolean; + /** Updates the `showRelativeTime` setting. */ + setShowRelativeTime: (showRelativeTime: boolean) => void; +}; + +/** + * Props for any component that renders a single log message entry. + * + * @param recordCell - A reactive `Cell` containing a single `LogRecord`. + * @param onUpdate - Optional callback triggered when the log entry updates. + */ +export type MessageComponentProps = { + recordCell: Cell, + onUpdate?: () => void, +}; + +/** + * Key used for the experiment filter predicate in the filter map, to exclude experiment logs from the developer logs. + */ +export const EXPERIMENT_FILTER_KEY = "experiment_filter"; +export const EXPERIMENT_LOGGER_NAME = "experiment"; diff --git a/src/components/Logging/Filters.tsx b/src/components/Logging/Filters.tsx index 4b95de6..4a4a3ca 100644 --- a/src/components/Logging/Filters.tsx +++ b/src/components/Logging/Filters.tsx @@ -13,9 +13,8 @@ type Setter = (value: T | ((prev: T) => T)) => void; * Mapping of log level names to their corresponding numeric severity. * Used for comparison in log filtering predicates. */ -const optionMapping = new Map([ +const optionMapping: Map = new Map([ ["ALL", 0], - ["LLM", 9], ["DEBUG", 10], ["INFO", 20], ["WARNING", 30], @@ -93,7 +92,7 @@ function GlobalLevelFilter({ filterPredicates: Map; setFilterPredicates: Setter>; }) { - const selected = filterPredicates.get(GLOBAL_LOG_LEVEL_PREDICATE_KEY)?.value || "ALL"; + const selected = filterPredicates.get(GLOBAL_LOG_LEVEL_PREDICATE_KEY)?.value ?? "ALL"; const setSelected = (selected: string | null) => { if (!selected || !optionMapping.has(selected)) return; diff --git a/src/components/Logging/Logging.module.css b/src/components/Logging/Logging.module.css index 6fc2988..ff4722d 100644 --- a/src/components/Logging/Logging.module.css +++ b/src/components/Logging/Logging.module.css @@ -5,7 +5,6 @@ flex-shrink: 0; box-shadow: 0 0 1rem black; - padding: 1rem 1rem 0 1rem; } .no-numbers { @@ -15,8 +14,6 @@ } .log-container { - margin-bottom: .5rem; - .accented-0, .accented-10 { background-color: color-mix(in oklab, canvas, rgb(159, 159, 159) 35%) } @@ -32,7 +29,7 @@ } .floating-button { - position: fixed; + position: absolute; bottom: 1rem; right: 1rem; box-shadow: 0 0 1rem rgba(0, 0, 0, 0.5); diff --git a/src/components/Logging/Logging.tsx b/src/components/Logging/Logging.tsx index 8c2101f..2419a81 100644 --- a/src/components/Logging/Logging.tsx +++ b/src/components/Logging/Logging.tsx @@ -1,38 +1,23 @@ -import {useEffect, useRef, useState} from "react"; -import {create} from "zustand"; - +import {type ComponentType, useEffect, useRef, useState} from "react"; import formatDuration from "../../utils/formatDuration.ts"; import {type LogFilterPredicate, type LogRecord, useLogs} from "./useLogs.ts"; import Filters from "./Filters.tsx"; import {type Cell, useCell} from "../../utils/cellStore.ts"; - import styles from "./Logging.module.css"; - +import { + EXPERIMENT_FILTER_KEY, + EXPERIMENT_LOGGER_NAME, + type LoggingSettings, + type MessageComponentProps +} from "./Definitions.ts"; +import {create} from "zustand"; /** - * Zustand store definition for managing user preferences related to logging. - * - * Includes flags for toggling relative timestamps and automatic scroll behavior. - */ -type LoggingSettings = { - /** Whether to display log timestamps as relative (e.g., "2m 15s ago") instead of absolute. */ - showRelativeTime: boolean; - /** Updates the `showRelativeTime` setting. */ - setShowRelativeTime: (showRelativeTime: boolean) => void; - /** Whether the log view should automatically scroll to the newest entry. */ - scrollToBottom: boolean; - /** Updates the `scrollToBottom` setting. */ - setScrollToBottom: (scrollToBottom: boolean) => void; -}; - -/** - * Global Zustand store for logging UI preferences. + * Local Zustand store for logging UI preferences. */ const useLoggingSettings = create((set) => ({ showRelativeTime: false, setShowRelativeTime: (showRelativeTime: boolean) => set({ showRelativeTime }), - scrollToBottom: true, - setScrollToBottom: (scrollToBottom: boolean) => set({ scrollToBottom }), })); /** @@ -45,13 +30,7 @@ const useLoggingSettings = create((set) => ({ * @param onUpdate - Optional callback triggered when the log entry updates. * @returns A JSX element displaying a formatted log message. */ -function LogMessage({ - recordCell, - onUpdate, -}: { - recordCell: Cell, - onUpdate?: () => void, -}) { +function LogMessage({ recordCell, onUpdate }: MessageComponentProps) { const { showRelativeTime, setShowRelativeTime } = useLoggingSettings(); const record = useCell(recordCell); @@ -69,7 +48,7 @@ function LogMessage({ /** Simplifies the logger name by showing only the last path segment. */ const normalizedName = record.name.split(".").pop() || record.name; - // Notify parent component (e.g. for scroll updates) when this record changes. + // Notify the parent component (e.g., for scroll updates) when this record changes. useEffect(() => { if (onUpdate) onUpdate(); }, [record, onUpdate]); @@ -77,11 +56,10 @@ function LogMessage({ return
    {record.levelname} - setShowRelativeTime(!showRelativeTime)} - >{showRelativeTime - ? formatDuration(record.relativeCreated) - : new Date(record.created * 1000).toLocaleTimeString() + setShowRelativeTime(!showRelativeTime)}>{ + showRelativeTime + ? formatDuration(record.relativeCreated) + : new Date(record.created * 1000).toLocaleTimeString() }
    @@ -100,12 +78,18 @@ function LogMessage({ * - A floating "Scroll to bottom" button when not at the bottom. * * @param recordCells - Array of reactive log records to display. + * @param MessageComponent - A component to use to render each log message entry. * @returns A scrollable log list component. */ -function LogMessages({ recordCells }: { recordCells: Cell[] }) { +export function LogMessages({ + recordCells, + MessageComponent, +}: { + recordCells: Cell[], + MessageComponent: ComponentType, +}) { const scrollableRef = useRef(null); - const lastElementRef = useRef(null) - const { scrollToBottom, setScrollToBottom } = useLoggingSettings(); + const [scrollToBottom, setScrollToBottom] = useState(true); // Disable auto-scroll if the user manually scrolls. useEffect(() => { @@ -124,30 +108,28 @@ function LogMessages({ recordCells }: { recordCells: Cell[] }) { }, [scrollableRef, setScrollToBottom]); /** - * Scrolls the last log message into view if auto-scroll is enabled, - * or if forced (e.g., user clicks "Scroll to bottom"). + * Scrolls the log messages to the bottom, making the latest messages visible. * * @param force - If true, forces scrolling even if `scrollToBottom` is false. */ - function scrollLastElementIntoView(force = false) { - if ((!scrollToBottom && !force) || !lastElementRef.current) return; - lastElementRef.current.scrollIntoView({ behavior: "smooth" }); + function showBottom(force = false) { + if ((!scrollToBottom && !force) || !scrollableRef.current) return; + scrollableRef.current.scrollTo({top: scrollableRef.current.scrollHeight, left: 0, behavior: "smooth"}); } - return
    + return
      {recordCells.map((recordCell, i) => (
    1. - +
    2. ))} -
    {!scrollToBottom &&
    - {/* LOGS TODO: add actual logs */} - + {/* LOGS */} + {/* FOOTER */}
    diff --git a/src/pages/MonitoringPage/components/ExperimentLogs.module.css b/src/pages/MonitoringPage/components/ExperimentLogs.module.css new file mode 100644 index 0000000..b25dffe --- /dev/null +++ b/src/pages/MonitoringPage/components/ExperimentLogs.module.css @@ -0,0 +1,34 @@ +.logs { + /* grid-area used in MonitoringPage.module.css */ + grid-area: logs; + box-shadow: var(--panel-shadow); + + height: 900px; + width: 450px; + + .live { + width: .5rem; + height: .5rem; + left: .5rem; + background: red; + border-radius: 50%; + } +} + +.chat-message.alternate { + align-items: end; + text-align: end; + + background-color: color-mix(in oklab, canvas, 75% #86c4fa); + + .message-head { + flex-direction: row-reverse; + } +} + +.download-list { + box-sizing: border-box; + list-style: none; + height: 50dvh; + min-width: 300px; +} \ No newline at end of file diff --git a/src/pages/MonitoringPage/components/ExperimentLogs.tsx b/src/pages/MonitoringPage/components/ExperimentLogs.tsx new file mode 100644 index 0000000..ff0d118 --- /dev/null +++ b/src/pages/MonitoringPage/components/ExperimentLogs.tsx @@ -0,0 +1,186 @@ +import styles from "./ExperimentLogs.module.css"; +import {LogMessages} from "../../../components/Logging/Logging.tsx"; +import {useEffect, useMemo, useState} from "react"; +import {type LogFilterPredicate, type LogRecord, useLogs} from "../../../components/Logging/useLogs.ts"; +import capitalize from "../../../utils/capitalize.ts"; +import {useCell} from "../../../utils/cellStore.ts"; +import { + EXPERIMENT_FILTER_KEY, + EXPERIMENT_LOGGER_NAME, + type LoggingSettings, + type MessageComponentProps, +} from "../../../components/Logging/Definitions.ts"; +import formatDuration from "../../../utils/formatDuration.ts"; +import {create} from "zustand"; +import Dialog from "../../../components/Dialog.tsx"; +import delayedResolve from "../../../utils/delayedResolve.ts"; + +/** + * Local Zustand store for logging UI preferences. + */ +const useLoggingSettings = create((set) => ({ + showRelativeTime: false, + setShowRelativeTime: (showRelativeTime: boolean) => set({ showRelativeTime }), +})); + +/** + * A dedicated component for rendering chat messages. + * + * @param record The chat record to render. + */ +function ChatMessage({ record }: { record: LogRecord }) { + const { showRelativeTime, setShowRelativeTime } = useLoggingSettings(); + + const reverse = record.role === "user" ? styles.alternate : ""; + + return
    +
    + {capitalize(record.role ?? "unknown")} + + setShowRelativeTime(!showRelativeTime)}>{ + showRelativeTime + ? formatDuration(record.relativeCreated) + : new Date(record.created * 1000).toLocaleTimeString() + } +
    + {record.message} +
    +} + +/** + * A generic log message component showing the log level, time, and message text. + * + * @param record The log record to render. + */ +function DefaultMessage({ record }: { record: LogRecord }) { + const { showRelativeTime, setShowRelativeTime } = useLoggingSettings(); + + return
    +
    + {record.levelname} + + setShowRelativeTime(!showRelativeTime)}>{ + showRelativeTime + ? formatDuration(record.relativeCreated) + : new Date(record.created * 1000).toLocaleTimeString() + } +
    + {record.message} +
    ; +} + +/** + * A custom component for rendering experiment messages, which might include chat messages. + * + * @param recordCell The cell containing the log record to render. + * @param onUpdate A callback to notify the parent component when the record changes. + */ +function ExperimentMessage({recordCell, onUpdate}: MessageComponentProps) { + const record = useCell(recordCell); + + // Notify the parent component (e.g., for scroll updates) when this record changes. + useEffect(() => { + if (onUpdate) onUpdate(); + }, [record, onUpdate]); + + if (record.levelname == "CHAT") { + return + } else { + return + } +} + +/** + * A download dialog listing experiment logs to download. + * + * @param filenames The list of available experiment logs to download. + * @param refresh A callback to refresh the list of available experiment logs. + */ +function DownloadScreen({filenames, refresh}: {filenames: string[] | null, refresh: () => void}) { + const list = (() => { + if (filenames == null) return
    +

    Loading...

    +
    ; + if (filenames.length === 0) return
    +

    No files available.

    +
    + + return
      + {filenames!.map((filename) => ( +
    1. {filename}
    2. + ))} +
    ; + })(); + + return
    +

    Select a file to download:

    + {list} + +
    ; +} + +/** + * A button that opens a download dialog for experiment logs when pressed. + */ +function DownloadButton() { + const [showModal, setShowModal] = useState(false); + const [filenames, setFilenames] = useState(null); + + async function getFiles(): Promise { + const response = await fetch("http://localhost:8000/api/logs/files"); + const files = await response.json(); + files.sort(); + return files; + } + + useEffect(() => { + getFiles().then(setFilenames); + }, [showModal]); + + return <> + + setShowModal(false)} classname={"padding-0 round-lg"}> + { + setFilenames(null); + const files = await delayedResolve(getFiles(), 250); + setFilenames(files); + }} /> + + ; +} + +/** + * A component for rendering experiment logs. This component uses the `useLogs` hook with a filter to show only + * experiment logs. + */ +export default function ExperimentLogs() { + // Show only experiment logs in this logger + const filters = useMemo(() => new Map([ + [ + EXPERIMENT_FILTER_KEY, + { + predicate: (r) => r.name == EXPERIMENT_LOGGER_NAME, + priority: 999, + value: null, + } as LogFilterPredicate, + ], + ]), []); + + const { filteredLogs } = useLogs(filters); + + return ; +} \ No newline at end of file diff --git a/src/utils/capitalize.ts b/src/utils/capitalize.ts new file mode 100644 index 0000000..9142c62 --- /dev/null +++ b/src/utils/capitalize.ts @@ -0,0 +1,3 @@ +export default function (s: string) { + return s.charAt(0).toUpperCase() + s.slice(1); +} diff --git a/src/utils/delayedResolve.ts b/src/utils/delayedResolve.ts new file mode 100644 index 0000000..702bc2e --- /dev/null +++ b/src/utils/delayedResolve.ts @@ -0,0 +1,7 @@ +export default async function (promise: Promise, minDelayMs: number): Promise { + const [result] = await Promise.all([ + promise, + new Promise(resolve => setTimeout(resolve, minDelayMs)) + ]); + return result; +} diff --git a/src/utils/priorityFiltering.ts b/src/utils/priorityFiltering.ts index 7638f34..e409790 100644 --- a/src/utils/priorityFiltering.ts +++ b/src/utils/priorityFiltering.ts @@ -4,7 +4,7 @@ export type PriorityFilterPredicate = { } /** - * Applies a list of priority predicates to an element. For all predicates that don't return null, if the ones with the highest level return true, then this function returns true. + * Applies a list of priority predicates to an element. For all predicates that don't return null, if the ones with the highest level return true, then this function returns true. Or conversely, if the one with the highest level returns false, then this function returns false. * @param element The element to apply the predicates to. * @param predicates The list of predicates to apply. */ diff --git a/test/components/Logging/Logging.test.tsx b/test/components/Logging/Logging.test.tsx index a3b6d09..36692a9 100644 --- a/test/components/Logging/Logging.test.tsx +++ b/test/components/Logging/Logging.test.tsx @@ -11,8 +11,6 @@ const loggingStoreRef: { current: null | { setState: (state: Partial void; - scrollToBottom: boolean; - setScrollToBottom: (scroll: boolean) => void; }; jest.mock("zustand", () => { @@ -59,8 +57,8 @@ type LoggingComponent = typeof import("../../../src/components/Logging/Logging.t let Logging: LoggingComponent; beforeAll(async () => { - if (!Element.prototype.scrollIntoView) { - Object.defineProperty(Element.prototype, "scrollIntoView", { + if (!Element.prototype.scrollTo) { + Object.defineProperty(Element.prototype, "scrollTo", { configurable: true, writable: true, value: function () {}, @@ -84,7 +82,6 @@ afterEach(() => { function resetLoggingStore() { loggingStoreRef.current?.setState({ showRelativeTime: false, - scrollToBottom: true, }); } @@ -151,7 +148,7 @@ describe("Logging component", () => { ]; mockUseLogs.mockReturnValue({filteredLogs: logs, distinctNames: new Set()}); - const scrollSpy = jest.spyOn(Element.prototype, "scrollIntoView").mockImplementation(() => {}); + const scrollSpy = jest.spyOn(Element.prototype, "scrollTo").mockImplementation(() => {}); const user = userEvent.setup(); const view = render(); @@ -175,7 +172,7 @@ describe("Logging component", () => { const logCell = makeCell({message: "Initial", firstRelativeCreated: 42}); mockUseLogs.mockReturnValue({filteredLogs: [logCell], distinctNames: new Set()}); - const scrollSpy = jest.spyOn(Element.prototype, "scrollIntoView").mockImplementation(() => {}); + const scrollSpy = jest.spyOn(Element.prototype, "scrollTo").mockImplementation(() => {}); render(); await waitFor(() => { @@ -209,7 +206,7 @@ describe("Logging component", () => { const initialMap = firstProps.filterPredicates; expect(initialMap).toBeInstanceOf(Map); - expect(initialMap.size).toBe(0); + expect(initialMap.size).toBe(1); // Initially, only filter out experiment logs expect(mockUseLogs).toHaveBeenCalledWith(initialMap); const updatedPredicate: LogFilterPredicate = { diff --git a/test/setupTests.ts b/test/setupTests.ts index 748f491..5cb4da6 100644 --- a/test/setupTests.ts +++ b/test/setupTests.ts @@ -1,2 +1,46 @@ // Adds jest-dom matchers for React testing library -import '@testing-library/jest-dom'; \ No newline at end of file +import '@testing-library/jest-dom'; + +// Minimal browser API mocks for the test environment. +// Fetch +if (!globalThis.fetch) { + globalThis.fetch = jest.fn(async () => ({ + ok: true, + status: 200, + json: async () => [], + text: async () => '', + })) as unknown as typeof fetch; +} + +// EventSource +if (!globalThis.EventSource) { + class MockEventSource { + url: string; + readyState = 1; + onmessage: ((event: MessageEvent) => void) | null = null; + onerror: ((event: Event) => void) | null = null; + onopen: ((event: Event) => void) | null = null; + + constructor(url: string) { + this.url = url; + } + + close() { + this.readyState = 2; + } + + addEventListener(type: string, listener: (event: MessageEvent) => void) { + if (type === 'message') { + this.onmessage = listener; + } + } + + removeEventListener(type: string, listener: (event: MessageEvent) => void) { + if (type === 'message' && this.onmessage === listener) { + this.onmessage = null; + } + } + } + + globalThis.EventSource = MockEventSource as unknown as typeof EventSource; +} diff --git a/test/utils/capitalize.test.ts b/test/utils/capitalize.test.ts new file mode 100644 index 0000000..e6b7cf2 --- /dev/null +++ b/test/utils/capitalize.test.ts @@ -0,0 +1,34 @@ +import capitalize from "../../src/utils/capitalize.ts"; + +describe('capitalize', () => { + it('capitalizes the first letter of a lowercase word', () => { + expect(capitalize('hello')).toBe('Hello'); + }); + + it('keeps the first letter capitalized if already uppercase', () => { + expect(capitalize('Hello')).toBe('Hello'); + }); + + it('handles single character strings', () => { + expect(capitalize('a')).toBe('A'); + expect(capitalize('A')).toBe('A'); + }); + + it('returns empty string for empty input', () => { + expect(capitalize('')).toBe(''); + }); + + it('only capitalizes the first letter, leaving the rest unchanged', () => { + expect(capitalize('hELLO')).toBe('HELLO'); + expect(capitalize('hello world')).toBe('Hello world'); + }); + + it('handles strings starting with numbers', () => { + expect(capitalize('123abc')).toBe('123abc'); + }); + + it('handles strings starting with special characters', () => { + expect(capitalize('!hello')).toBe('!hello'); + expect(capitalize(' hello')).toBe(' hello'); + }); +}); diff --git a/test/utils/delayedResolve.test.ts b/test/utils/delayedResolve.test.ts new file mode 100644 index 0000000..2c111c6 --- /dev/null +++ b/test/utils/delayedResolve.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, beforeEach, afterEach } from '@jest/globals'; +import delayedResolve from "../../src/utils/delayedResolve.ts"; + +describe('delayedResolve', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('returns the resolved value of the promise', async () => { + const resultPromise = delayedResolve(Promise.resolve('hello'), 100); + await jest.advanceTimersByTimeAsync(100); + expect(await resultPromise).toBe('hello'); + }); + + it('waits at least minDelayMs before resolving', async () => { + let resolved = false; + const resultPromise = delayedResolve(Promise.resolve('fast'), 100); + resultPromise.then(() => { resolved = true; }); + + await jest.advanceTimersByTimeAsync(50); + expect(resolved).toBe(false); + + await jest.advanceTimersByTimeAsync(50); + expect(resolved).toBe(true); + }); + + it('resolves immediately after slow promise if it exceeds minDelayMs', async () => { + let resolved = false; + const slowPromise = new Promise(resolve => + setTimeout(() => resolve('slow'), 150) + ); + const resultPromise = delayedResolve(slowPromise, 50); + resultPromise.then(() => { resolved = true; }); + + await jest.advanceTimersByTimeAsync(50); + expect(resolved).toBe(false); + + await jest.advanceTimersByTimeAsync(100); + expect(resolved).toBe(true); + expect(await resultPromise).toBe('slow'); + }); + + it('propagates rejections from the promise', async () => { + const error = new Error('test error'); + const rejectedPromise = Promise.reject(error); + + const resultPromise = delayedResolve(rejectedPromise, 100); + const assertion = expect(resultPromise).rejects.toThrow('test error'); + + await jest.advanceTimersByTimeAsync(100); + + await assertion; + }); + + it('works with different value types', async () => { + const test = async (value: T) => { + const resultPromise = delayedResolve(Promise.resolve(value), 10); + await jest.advanceTimersByTimeAsync(10); + return resultPromise; + }; + + expect(await test(42)).toBe(42); + expect(await test({ foo: 'bar' })).toEqual({ foo: 'bar' }); + expect(await test([1, 2, 3])).toEqual([1, 2, 3]); + expect(await test(null)).toBeNull(); + }); + + it('handles zero delay', async () => { + const resultPromise = delayedResolve(Promise.resolve('instant'), 0); + await jest.advanceTimersByTimeAsync(0); + expect(await resultPromise).toBe('instant'); + }); +}); -- 2.49.1 From eb5a6cddd792bb3572be14e59ffd8fa5bf657ec7 Mon Sep 17 00:00:00 2001 From: "Gerla, J. (Justin)" Date: Wed, 28 Jan 2026 10:34:36 +0000 Subject: [PATCH 184/184] chore: added copyright strings and removed template page --- src/App.css | 5 ++ src/App.tsx | 6 +- src/components/Logging/Filters.module.css | 5 ++ src/components/Logging/Filters.tsx | 3 + src/components/Logging/Logging.module.css | 5 ++ src/components/Logging/Logging.tsx | 3 + src/components/Logging/useLogs.ts | 3 + src/components/MultilineTextField.tsx | 3 + src/components/ScrollIntoView.tsx | 3 + src/components/TextField.module.css | 5 ++ src/components/TextField.tsx | 3 + src/components/components.tsx | 3 + src/index.css | 6 ++ src/main.tsx | 3 + src/pages/ConnectedRobots/ConnectedRobots.tsx | 3 + src/pages/Home/Home.module.css | 5 ++ src/pages/Home/Home.tsx | 4 +- .../MonitoringPage/MonitoringPage.module.css | 5 ++ src/pages/MonitoringPage/MonitoringPage.tsx | 7 +- src/pages/MonitoringPage/MonitoringPageAPI.ts | 3 + .../MonitoringPageComponents.tsx | 3 + src/pages/Robot/Robot.tsx | 3 + src/pages/TemplatePage/Template.tsx | 11 --- src/pages/VisProgPage/VisProg.module.css | 5 ++ src/pages/VisProgPage/VisProg.tsx | 7 +- src/pages/VisProgPage/VisProgLogic.ts | 5 +- src/pages/VisProgPage/VisProgLogic.tsx | 43 ---------- .../visualProgrammingUI/EditorUndoRedo.ts | 3 + .../visualProgrammingUI/HandleRuleLogic.ts | 3 + .../visualProgrammingUI/HandleRules.ts | 3 + .../visualProgrammingUI/NodeRegistry.ts | 3 + .../visualProgrammingUI/VisProgStores.tsx | 3 + .../visualProgrammingUI/VisProgTypes.tsx | 3 + .../components/DragDropSidebar.tsx | 3 + .../components/EditorWarnings.tsx | 3 + .../components/GestureValueEditor.module.css | 6 +- .../components/GestureValueEditor.tsx | 3 + .../components/NodeComponents.tsx | 3 + .../components/Plan.default.ts | 3 + .../visualProgrammingUI/components/Plan.tsx | 3 + .../components/PlanEditingFunctions.tsx | 3 + .../components/PlanEditor.module.css | 5 ++ .../components/PlanEditor.tsx | 3 + .../components/RuleBasedHandle.module.css | 5 ++ .../components/RuleBasedHandle.tsx | 3 + .../components/SaveLoadPanel.module.css | 5 ++ .../components/SaveLoadPanel.tsx | 3 + .../components/WarningSidebar.module.css | 5 ++ .../components/WarningSidebar.tsx | 3 + .../nodes/BasicBeliefNode.default.ts | 3 + .../nodes/BasicBeliefNode.tsx | 3 + .../nodes/BeliefGlobals.ts | 3 + .../nodes/EndNode.default.ts | 3 + .../visualProgrammingUI/nodes/EndNode.tsx | 3 + .../nodes/GoalNode.default.ts | 3 + .../visualProgrammingUI/nodes/GoalNode.tsx | 3 + .../nodes/InferredBeliefNode.default.ts | 3 + .../nodes/InferredBeliefNode.module.css | 5 ++ .../nodes/InferredBeliefNode.tsx | 3 + .../nodes/NormNode.default.ts | 3 + .../visualProgrammingUI/nodes/NormNode.tsx | 3 + .../nodes/PhaseNode.default.ts | 3 + .../visualProgrammingUI/nodes/PhaseNode.tsx | 3 + .../nodes/StartNode.default.ts | 3 + .../visualProgrammingUI/nodes/StartNode.tsx | 3 + .../nodes/TriggerNode.default.ts | 3 + .../visualProgrammingUI/nodes/TriggerNode.tsx | 3 + src/utils/SaveLoad.ts | 3 + src/utils/cellStore.ts | 3 + src/utils/duplicateIndices.ts | 3 + src/utils/formatDuration.ts | 3 + src/utils/orderPhaseNodes.ts | 3 + src/utils/priorityFiltering.ts | 3 + src/utils/programStore.ts | 3 + test/components.test.tsx | 3 + test/components/Logging/Filters.test.tsx | 3 + test/components/Logging/Logging.test.tsx | 3 + test/components/Logging/useLogs.test.tsx | 3 + test/eslint.config.js.ts | 3 + .../connectedRobots/ConnectedRobots.test.tsx | 3 + .../monitoringPage/MonitoringPage.test.tsx | 9 +- .../monitoringPage/MonitoringPageAPI.test.ts | 3 + .../MonitoringPageComponents.test.tsx | 3 + test/pages/robot/Robot.test.tsx | 3 + test/pages/simpleProgram/SimpleProgram.tsx | 83 ------------------- .../EditorUndoRedo.test.ts | 3 + .../visualProgrammingUI/GraphReducer.test.ts | 3 + .../HandleRuleLogic.test.ts | 3 + .../visualProgrammingUI/HandleRules.test.ts | 3 + .../VisProgStores.test.tsx | 3 + .../components/DragDropSidebar.test.tsx | 3 + .../components/EditorWarnings.test.tsx | 3 + .../components/GestureValueEditor.test.tsx | 3 + .../components/NodeComponents.test.tsx | 3 + .../components/PlanEditor.test.tsx | 3 + .../components/SaveLoadPanel.test.tsx | 3 + .../components/ScrollIntoView.test.tsx | 3 + .../components/WarningSidebar.test.tsx | 3 + .../nodes/BeliefGlobals.test.ts | 3 + .../nodes/BeliefNode.test.tsx | 3 + .../nodes/GoalNode.test.tsx | 3 + .../nodes/InferredBeliefNode.test.tsx | 3 + .../nodes/NormNode.test.tsx | 3 + .../nodes/PhaseNode.test.tsx | 3 + .../nodes/StartNode.test.tsx | 3 + .../nodes/TriggerNode.test.tsx | 3 + .../nodes/UniversalNodes.test.tsx | 3 + test/setupFlowTests.ts | 3 + test/setupTests.ts | 3 + test/test-utils/mocks.ts | 3 + test/test-utils/test-utils.tsx | 3 + test/utils/cellStore.test.tsx | 3 + test/utils/duplicateIndices.test.ts | 3 + test/utils/formatDuration.test.ts | 3 + test/utils/orderPhaseNodes.test.ts | 3 + test/utils/priorityFiltering.test.ts | 3 + test/utils/programStore.test.ts | 3 + 117 files changed, 380 insertions(+), 149 deletions(-) delete mode 100644 src/pages/TemplatePage/Template.tsx delete mode 100644 src/pages/VisProgPage/VisProgLogic.tsx delete mode 100644 test/pages/simpleProgram/SimpleProgram.tsx diff --git a/src/App.css b/src/App.css index e0c9542..f05b2b1 100644 --- a/src/App.css +++ b/src/App.css @@ -1,3 +1,8 @@ +{/* +This program has been developed by students from the bachelor Computer Science at Utrecht +University within the Software Project course. +© Copyright Utrecht University (Department of Information and Computing Sciences) +*/} .logopepper { height: 8em; padding: 1.5em; diff --git a/src/App.tsx b/src/App.tsx index 8e09ba6..c9ac2d7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,8 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import { Routes, Route, Link } from 'react-router' import './App.css' -import TemplatePage from './pages/TemplatePage/Template.tsx' import Home from './pages/Home/Home.tsx' import Robot from './pages/Robot/Robot.tsx'; import ConnectedRobots from './pages/ConnectedRobots/ConnectedRobots.tsx' @@ -15,6 +17,7 @@ function App(){ return ( <>
    + © Utrecht University (ICS) Home
    @@ -22,7 +25,6 @@ function App(){
    } /> - } /> } /> } /> } /> diff --git a/src/components/Logging/Filters.module.css b/src/components/Logging/Filters.module.css index 405560c..3353866 100644 --- a/src/components/Logging/Filters.module.css +++ b/src/components/Logging/Filters.module.css @@ -1,3 +1,8 @@ +{/* +This program has been developed by students from the bachelor Computer Science at Utrecht +University within the Software Project course. +© Copyright Utrecht University (Department of Information and Computing Sciences) +*/} .filter-root { position: relative; display: flex; diff --git a/src/components/Logging/Filters.tsx b/src/components/Logging/Filters.tsx index 4a4a3ca..9b4f611 100644 --- a/src/components/Logging/Filters.tsx +++ b/src/components/Logging/Filters.tsx @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import {useEffect, useRef, useState} from "react"; import type {LogFilterPredicate} from "./useLogs.ts"; diff --git a/src/components/Logging/Logging.module.css b/src/components/Logging/Logging.module.css index ff4722d..9b26530 100644 --- a/src/components/Logging/Logging.module.css +++ b/src/components/Logging/Logging.module.css @@ -1,3 +1,8 @@ +{/* +This program has been developed by students from the bachelor Computer Science at Utrecht +University within the Software Project course. +© Copyright Utrecht University (Department of Information and Computing Sciences) +*/} .logging-container { box-sizing: border-box; diff --git a/src/components/Logging/Logging.tsx b/src/components/Logging/Logging.tsx index 2419a81..8b1bf71 100644 --- a/src/components/Logging/Logging.tsx +++ b/src/components/Logging/Logging.tsx @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import {type ComponentType, useEffect, useRef, useState} from "react"; import formatDuration from "../../utils/formatDuration.ts"; import {type LogFilterPredicate, type LogRecord, useLogs} from "./useLogs.ts"; diff --git a/src/components/Logging/useLogs.ts b/src/components/Logging/useLogs.ts index d60c544..73016db 100644 --- a/src/components/Logging/useLogs.ts +++ b/src/components/Logging/useLogs.ts @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import {useCallback, useEffect, useRef, useState} from "react"; import {applyPriorityPredicates, type PriorityFilterPredicate} from "../../utils/priorityFiltering.ts"; diff --git a/src/components/MultilineTextField.tsx b/src/components/MultilineTextField.tsx index ad88513..a2af183 100644 --- a/src/components/MultilineTextField.tsx +++ b/src/components/MultilineTextField.tsx @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import { useEffect, useRef, useState } from "react"; import styles from "./TextField.module.css"; diff --git a/src/components/ScrollIntoView.tsx b/src/components/ScrollIntoView.tsx index df5148f..7e60677 100644 --- a/src/components/ScrollIntoView.tsx +++ b/src/components/ScrollIntoView.tsx @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import {useEffect, useRef} from "react"; /** diff --git a/src/components/TextField.module.css b/src/components/TextField.module.css index 308ec43..139a55c 100644 --- a/src/components/TextField.module.css +++ b/src/components/TextField.module.css @@ -1,3 +1,8 @@ +{/* +This program has been developed by students from the bachelor Computer Science at Utrecht +University within the Software Project course. +© Copyright Utrecht University (Department of Information and Computing Sciences) +*/} .text-field { border: 1px solid transparent; border-radius: 5pt; diff --git a/src/components/TextField.tsx b/src/components/TextField.tsx index 6395e18..9d993a9 100644 --- a/src/components/TextField.tsx +++ b/src/components/TextField.tsx @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import {useEffect, useState} from "react"; import styles from "./TextField.module.css"; diff --git a/src/components/components.tsx b/src/components/components.tsx index 7ee7f0d..1a1c5b7 100644 --- a/src/components/components.tsx +++ b/src/components/components.tsx @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import { useState } from 'react' /** diff --git a/src/index.css b/src/index.css index b24adcf..ff4607f 100644 --- a/src/index.css +++ b/src/index.css @@ -1,3 +1,9 @@ +{/* +This program has been developed by students from the bachelor Computer Science at Utrecht +University within the Software Project course. +© Copyright Utrecht University (Department of Information and Computing Sciences) +*/} + :root { font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; line-height: 1.5; diff --git a/src/main.tsx b/src/main.tsx index ae3d161..edf41bd 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import { BrowserRouter } from 'react-router' diff --git a/src/pages/ConnectedRobots/ConnectedRobots.tsx b/src/pages/ConnectedRobots/ConnectedRobots.tsx index 176f8d5..bddfbd4 100644 --- a/src/pages/ConnectedRobots/ConnectedRobots.tsx +++ b/src/pages/ConnectedRobots/ConnectedRobots.tsx @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import { useEffect, useState } from 'react' /** diff --git a/src/pages/Home/Home.module.css b/src/pages/Home/Home.module.css index cd880b3..11499aa 100644 --- a/src/pages/Home/Home.module.css +++ b/src/pages/Home/Home.module.css @@ -1,3 +1,8 @@ +{/* +This program has been developed by students from the bachelor Computer Science at Utrecht +University within the Software Project course. +© Copyright Utrecht University (Department of Information and Computing Sciences) +*/} .read_the_docs { color: #888; } diff --git a/src/pages/Home/Home.tsx b/src/pages/Home/Home.tsx index c998e25..b2ccb5c 100644 --- a/src/pages/Home/Home.tsx +++ b/src/pages/Home/Home.tsx @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import { Link } from 'react-router' import pepperLogo from '../../assets/pepper_transp2_small.svg' import styles from './Home.module.css' @@ -21,7 +24,6 @@ function Home() {
    Robot Interaction → Editor → - Template → Connected Robots →
    diff --git a/src/pages/MonitoringPage/MonitoringPage.module.css b/src/pages/MonitoringPage/MonitoringPage.module.css index 73f94e0..0603a03 100644 --- a/src/pages/MonitoringPage/MonitoringPage.module.css +++ b/src/pages/MonitoringPage/MonitoringPage.module.css @@ -1,3 +1,8 @@ +{/* +This program has been developed by students from the bachelor Computer Science at Utrecht +University within the Software Project course. +© Copyright Utrecht University (Department of Information and Computing Sciences) +*/} .dashboardContainer { display: grid; grid-template-columns: 2fr 1fr; /* Left = content, Right = logs */ diff --git a/src/pages/MonitoringPage/MonitoringPage.tsx b/src/pages/MonitoringPage/MonitoringPage.tsx index 29aa9b0..a1634a3 100644 --- a/src/pages/MonitoringPage/MonitoringPage.tsx +++ b/src/pages/MonitoringPage/MonitoringPage.tsx @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import React, { useCallback, useState } from 'react'; import styles from './MonitoringPage.module.css'; @@ -15,7 +18,7 @@ import { type CondNormsStateUpdate, type PhaseUpdate } from "./MonitoringPageAPI"; -import { graphReducer, runProgramm } from '../VisProgPage/VisProgLogic.ts'; +import { graphReducer, runProgram } from '../VisProgPage/VisProgLogic.ts'; // Types import type { NormNodeData } from '../VisProgPage/visualProgrammingUI/nodes/NormNode'; @@ -132,7 +135,7 @@ function useExperimentLogic() { setGoalIndex(0); setIsFinished(false); - await runProgramm(); + await runProgram(); console.log("Experiment & UI successfully reset."); } catch (err) { console.error("Failed to reset program:", err); diff --git a/src/pages/MonitoringPage/MonitoringPageAPI.ts b/src/pages/MonitoringPage/MonitoringPageAPI.ts index c210968..1a9838c 100644 --- a/src/pages/MonitoringPage/MonitoringPageAPI.ts +++ b/src/pages/MonitoringPage/MonitoringPageAPI.ts @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import React, { useEffect } from 'react'; const API_BASE = "http://localhost:8000"; diff --git a/src/pages/MonitoringPage/MonitoringPageComponents.tsx b/src/pages/MonitoringPage/MonitoringPageComponents.tsx index d1d2854..ef4f561 100644 --- a/src/pages/MonitoringPage/MonitoringPageComponents.tsx +++ b/src/pages/MonitoringPage/MonitoringPageComponents.tsx @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import React, { useEffect, useState } from 'react'; import styles from './MonitoringPage.module.css'; import { sendAPICall } from './MonitoringPageAPI'; diff --git a/src/pages/Robot/Robot.tsx b/src/pages/Robot/Robot.tsx index 803b2f5..49dc120 100644 --- a/src/pages/Robot/Robot.tsx +++ b/src/pages/Robot/Robot.tsx @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import { useState, useEffect, useRef } from 'react' /** diff --git a/src/pages/TemplatePage/Template.tsx b/src/pages/TemplatePage/Template.tsx deleted file mode 100644 index dc24adf..0000000 --- a/src/pages/TemplatePage/Template.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import Counter from '../../components/components.tsx' - -function TemplatePage() { - return ( - <> - - - ) -} - -export default TemplatePage \ No newline at end of file diff --git a/src/pages/VisProgPage/VisProg.module.css b/src/pages/VisProgPage/VisProg.module.css index 8a0003c..05a5fc6 100644 --- a/src/pages/VisProgPage/VisProg.module.css +++ b/src/pages/VisProgPage/VisProg.module.css @@ -1,3 +1,8 @@ +{/* +This program has been developed by students from the bachelor Computer Science at Utrecht +University within the Software Project course. +© Copyright Utrecht University (Department of Information and Computing Sciences) +*/} /* editor UI */ .inner-editor-container { diff --git a/src/pages/VisProgPage/VisProg.tsx b/src/pages/VisProgPage/VisProg.tsx index 590f99e..3398d1c 100644 --- a/src/pages/VisProgPage/VisProg.tsx +++ b/src/pages/VisProgPage/VisProg.tsx @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import { Background, Controls, @@ -20,7 +23,7 @@ import styles from './VisProg.module.css' 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'; +import {graphReducer, runProgram} from './VisProgLogic.ts'; // --| config starting params for flow |-- @@ -218,7 +221,7 @@ function VisProgPage() { const phases = graphReducer(); // reduce graph setProgramState({ phases }); // <-- save to store setShowSimpleProgram(true); // show SimpleProgram - runProgramm(); // send to backend if needed + runProgram(); // send to backend if needed }; if (showSimpleProgram) { diff --git a/src/pages/VisProgPage/VisProgLogic.ts b/src/pages/VisProgPage/VisProgLogic.ts index 69c7f77..696a8a7 100644 --- a/src/pages/VisProgPage/VisProgLogic.ts +++ b/src/pages/VisProgPage/VisProgLogic.ts @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import useProgramStore from "../../utils/programStore"; import orderPhaseNodeArray from "../../utils/orderPhaseNodes"; import useFlowStore from './visualProgrammingUI/VisProgStores'; @@ -20,7 +23,7 @@ export function graphReducer() { /** * Outputs the prepared program to the console and sends it to the backend */ -export function runProgramm() { +export function runProgram() { const phases = graphReducer(); const program = {phases} console.log(JSON.stringify(program, null, 2)); diff --git a/src/pages/VisProgPage/VisProgLogic.tsx b/src/pages/VisProgPage/VisProgLogic.tsx deleted file mode 100644 index 3753a3f..0000000 --- a/src/pages/VisProgPage/VisProgLogic.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import useProgramStore from "../../utils/programStore"; -import orderPhaseNodeArray from "../../utils/orderPhaseNodes"; -import useFlowStore from './visualProgrammingUI/VisProgStores'; -import { NodeReduces } from './visualProgrammingUI/NodeRegistry'; -import type { PhaseNode } from "./visualProgrammingUI/nodes/PhaseNode"; - -/** - * Reduces the graph into its phases' information and recursively calls their reducing function - */ -export function graphReducer() { - const { nodes } = useFlowStore.getState(); - return orderPhaseNodeArray(nodes.filter((n) => n.type == 'phase') as PhaseNode []) - .map((n) => { - const reducer = NodeReduces['phase']; - return reducer(n, nodes) - }); -} - - -/** - * Outputs the prepared program to the console and sends it to the backend - */ -export function runProgram() { - const phases = graphReducer(); - const program = {phases} - console.log(JSON.stringify(program, null, 2)); - fetch( - "http://localhost:8000/program", - { - method: "POST", - headers: {"Content-Type": "application/json"}, - body: JSON.stringify(program), - } - ).then((res) => { - if (!res.ok) throw new Error("Failed communicating with the backend.") - console.log("Successfully sent the program to the backend."); - - // store reduced program in global program store for further use in the UI - // when the program was sent to the backend successfully: - useProgramStore.getState().setProgramState(structuredClone(program)); - }).catch(() => console.log("Failed to send program to the backend.")); - console.log(program); -} \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/EditorUndoRedo.ts b/src/pages/VisProgPage/visualProgrammingUI/EditorUndoRedo.ts index 4e45148..58c0bf8 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/EditorUndoRedo.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/EditorUndoRedo.ts @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import type {Edge, Node} from "@xyflow/react"; import type {StateCreator, StoreApi } from 'zustand/vanilla'; import type { diff --git a/src/pages/VisProgPage/visualProgrammingUI/HandleRuleLogic.ts b/src/pages/VisProgPage/visualProgrammingUI/HandleRuleLogic.ts index e212ed2..b420b1b 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/HandleRuleLogic.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/HandleRuleLogic.ts @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import {type Connection} from "@xyflow/react"; import {useEffect} from "react"; import useFlowStore from "./VisProgStores.tsx"; diff --git a/src/pages/VisProgPage/visualProgrammingUI/HandleRules.ts b/src/pages/VisProgPage/visualProgrammingUI/HandleRules.ts index aca415e..1c2301d 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/HandleRules.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/HandleRules.ts @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import { type HandleRule, ruleResult diff --git a/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts b/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts index a4285ec..e2ae8a1 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import EndNode, { EndConnectionTarget, EndConnectionSource, diff --git a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx index 65df21f..1f76b75 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import { create } from 'zustand'; import { applyNodeChanges, diff --git a/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx b/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx index a34a3e7..d715a1d 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) // VisProgTypes.ts import type { Edge, diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx index 338039a..53b540b 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import { useDraggable } from '@neodrag/react'; import { useReactFlow, type XYPosition } from '@xyflow/react'; import { type ReactNode, useCallback, useRef, useState } from 'react'; diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/EditorWarnings.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/EditorWarnings.tsx index 497aac6..c6d1495 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/EditorWarnings.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/EditorWarnings.tsx @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) /* contains all logic for the VisProgEditor warning system * * Missing but desirable features: diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/GestureValueEditor.module.css b/src/pages/VisProgPage/visualProgrammingUI/components/GestureValueEditor.module.css index 9d0f3e6..442c862 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/GestureValueEditor.module.css +++ b/src/pages/VisProgPage/visualProgrammingUI/components/GestureValueEditor.module.css @@ -1,4 +1,8 @@ - +{/* +This program has been developed by students from the bachelor Computer Science at Utrecht +University within the Software Project course. +© Copyright Utrecht University (Department of Information and Computing Sciences) +*/} .gestureEditor { display: flex; flex-direction: column; diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/GestureValueEditor.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/GestureValueEditor.tsx index 3b5863a..19affa5 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/GestureValueEditor.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/GestureValueEditor.tsx @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import { useState, useRef } from "react"; import styles from './GestureValueEditor.module.css' diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/NodeComponents.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/NodeComponents.tsx index 2d9bbd8..669b47f 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/NodeComponents.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/NodeComponents.tsx @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import {NodeToolbar, useReactFlow} from '@xyflow/react'; import '@xyflow/react/dist/style.css'; import {type JSX, useState} from "react"; diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/Plan.default.ts b/src/pages/VisProgPage/visualProgrammingUI/components/Plan.default.ts index 87d7d94..26ea9af 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/Plan.default.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/components/Plan.default.ts @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import type { Plan, PlanElement } from "./Plan"; export const defaultPlan: Plan = { diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/Plan.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/Plan.tsx index 3bc2825..255354c 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/Plan.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/Plan.tsx @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import { type Node } from "@xyflow/react" import { GoalReduce } from "../nodes/GoalNode" diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/PlanEditingFunctions.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/PlanEditingFunctions.tsx index 9e7e446..c683881 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/PlanEditingFunctions.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/PlanEditingFunctions.tsx @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) // This file is to avoid sharing both functions and components which eslint dislikes. :) import type { GoalNode } from "../nodes/GoalNode" import type { Goal, Plan } from "./Plan" diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/PlanEditor.module.css b/src/pages/VisProgPage/visualProgrammingUI/components/PlanEditor.module.css index ed16c78..91e3f98 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/PlanEditor.module.css +++ b/src/pages/VisProgPage/visualProgrammingUI/components/PlanEditor.module.css @@ -1,3 +1,8 @@ +{/* +This program has been developed by students from the bachelor Computer Science at Utrecht +University within the Software Project course. +© Copyright Utrecht University (Department of Information and Computing Sciences) +*/} .planDialog { overflow:visible; width: 80vw; diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/PlanEditor.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/PlanEditor.tsx index 2c2d098..15a4d89 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/PlanEditor.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/PlanEditor.tsx @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import {useRef, useState} from "react"; import useFlowStore from "../VisProgStores.tsx"; import styles from './PlanEditor.module.css'; diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/RuleBasedHandle.module.css b/src/pages/VisProgPage/visualProgrammingUI/components/RuleBasedHandle.module.css index 582ec2d..963e46d 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/RuleBasedHandle.module.css +++ b/src/pages/VisProgPage/visualProgrammingUI/components/RuleBasedHandle.module.css @@ -1,3 +1,8 @@ +{/* +This program has been developed by students from the bachelor Computer Science at Utrecht +University within the Software Project course. +© Copyright Utrecht University (Department of Information and Computing Sciences) +*/} :global(.react-flow__handle.source){ border-radius: 100%; } diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/RuleBasedHandle.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/RuleBasedHandle.tsx index 2026b00..41f15d3 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/RuleBasedHandle.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/RuleBasedHandle.tsx @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import { Handle, type HandleProps, diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/SaveLoadPanel.module.css b/src/pages/VisProgPage/visualProgrammingUI/components/SaveLoadPanel.module.css index 9dbafa2..3b1733b 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/SaveLoadPanel.module.css +++ b/src/pages/VisProgPage/visualProgrammingUI/components/SaveLoadPanel.module.css @@ -1,3 +1,8 @@ +{/* +This program has been developed by students from the bachelor Computer Science at Utrecht +University within the Software Project course. +© Copyright Utrecht University (Department of Information and Computing Sciences) +*/} .save-load-panel { border-radius: 0 0 5pt 5pt; background-color: canvas; diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/SaveLoadPanel.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/SaveLoadPanel.tsx index 8cf4146..2c24c79 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/SaveLoadPanel.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/SaveLoadPanel.tsx @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import {type ChangeEvent, useRef, useState} from "react"; import useFlowStore from "../VisProgStores"; import visProgStyles from "../../VisProg.module.css"; diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.module.css b/src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.module.css index 82168dc..6134d7a 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.module.css +++ b/src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.module.css @@ -1,3 +1,8 @@ +{/* +This program has been developed by students from the bachelor Computer Science at Utrecht +University within the Software Project course. +© Copyright Utrecht University (Department of Information and Computing Sciences) +*/} .warnings-sidebar { min-width: auto; max-width: 340px; diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.tsx index 27a4684..56176eb 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.tsx @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import {useReactFlow, useStoreApi} from "@xyflow/react"; import clsx from "clsx"; import {useEffect, useState} from "react"; diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.default.ts b/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.default.ts index 01f1cfa..65c3e2b 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.default.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.default.ts @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import type { BasicBeliefNodeData } from "./BasicBeliefNode.tsx"; diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx index 4495745..1426e89 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import { type NodeProps, Position, diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/BeliefGlobals.ts b/src/pages/VisProgPage/visualProgrammingUI/nodes/BeliefGlobals.ts index b92c5b2..8253889 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/BeliefGlobals.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/BeliefGlobals.ts @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import {getOutgoers, type Node} from '@xyflow/react'; import {type HandleRule, type RuleResult, ruleResult} from "../HandleRuleLogic.ts"; import useFlowStore from "../VisProgStores.tsx"; diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.default.ts b/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.default.ts index 3fb5e43..23d2b0f 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.default.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.default.ts @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import type { EndNodeData } from "./EndNode"; /** diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx index 3bbfa14..9426ed8 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import { type NodeProps, Position, diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.default.ts b/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.default.ts index 76dc3d1..ce3a403 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.default.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.default.ts @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import type { GoalNodeData } from "./GoalNode"; /** diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx index 1974e99..a225299 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import { type NodeProps, Position, diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.default.ts b/src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.default.ts index 976ee16..26c3a47 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.default.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.default.ts @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import type { InferredBeliefNodeData } from "./InferredBeliefNode.tsx"; diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.module.css b/src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.module.css index 2f9b7ae..d6186ed 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.module.css +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.module.css @@ -1,3 +1,8 @@ +{/* +This program has been developed by students from the bachelor Computer Science at Utrecht +University within the Software Project course. +© Copyright Utrecht University (Department of Information and Computing Sciences) +*/} .operator-switch { display: inline-flex; align-items: center; diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.tsx index 924517b..d8c01c0 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.tsx @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import {getConnectedEdges, type Node, type NodeProps, Position, useNodeConnections} from '@xyflow/react'; import {useEffect, useState} from "react"; import styles from '../../VisProg.module.css'; diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.default.ts b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.default.ts index 185d232..dce1154 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.default.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.default.ts @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import type { NormNodeData } from "./NormNode"; /** diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx index 29a03df..1b4d200 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import { type NodeProps, Position, diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.default.ts b/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.default.ts index 73697eb..b854179 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.default.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.default.ts @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import type { PhaseNodeData } from "./PhaseNode"; /** diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx index e5f2b9b..3fbde48 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import { type NodeProps, Position, diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.default.ts b/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.default.ts index 0837e03..3812f64 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.default.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.default.ts @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import type { StartNodeData } from "./StartNode"; /** diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx index 925d1dd..1355dee 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import { type NodeProps, Position, diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.default.ts b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.default.ts index 9c1b92f..2da7d8b 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.default.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.default.ts @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import type { TriggerNodeData } from "./TriggerNode"; /** diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx index caa8c58..2bd3ebd 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import { type NodeProps, Position, diff --git a/src/utils/SaveLoad.ts b/src/utils/SaveLoad.ts index 4ea9666..2672ad4 100644 --- a/src/utils/SaveLoad.ts +++ b/src/utils/SaveLoad.ts @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import {type Edge, type Node } from "@xyflow/react"; export type SavedProject = { diff --git a/src/utils/cellStore.ts b/src/utils/cellStore.ts index 9c14695..8839351 100644 --- a/src/utils/cellStore.ts +++ b/src/utils/cellStore.ts @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import {useSyncExternalStore} from "react"; type Unsub = () => void; diff --git a/src/utils/duplicateIndices.ts b/src/utils/duplicateIndices.ts index 08a4d43..fee06a5 100644 --- a/src/utils/duplicateIndices.ts +++ b/src/utils/duplicateIndices.ts @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) /** * Find the indices of all elements that occur more than once. * diff --git a/src/utils/formatDuration.ts b/src/utils/formatDuration.ts index 2e9f88d..d731a5e 100644 --- a/src/utils/formatDuration.ts +++ b/src/utils/formatDuration.ts @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) /** * Format a time duration like `HH:MM:SS.mmm`. * diff --git a/src/utils/orderPhaseNodes.ts b/src/utils/orderPhaseNodes.ts index 00b7a26..c9d6de5 100644 --- a/src/utils/orderPhaseNodes.ts +++ b/src/utils/orderPhaseNodes.ts @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import type {PhaseNode} from "../pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx"; /** diff --git a/src/utils/priorityFiltering.ts b/src/utils/priorityFiltering.ts index e409790..5fd9dc7 100644 --- a/src/utils/priorityFiltering.ts +++ b/src/utils/priorityFiltering.ts @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) export type PriorityFilterPredicate = { priority: number; predicate: (element: T) => boolean | null; // The predicate and its priority are ignored if it returns null. diff --git a/src/utils/programStore.ts b/src/utils/programStore.ts index 4e12bb3..0c79e59 100644 --- a/src/utils/programStore.ts +++ b/src/utils/programStore.ts @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import {create} from "zustand"; // the type of a reduced program diff --git a/test/components.test.tsx b/test/components.test.tsx index c1a39bb..c138ddb 100644 --- a/test/components.test.tsx +++ b/test/components.test.tsx @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import userEvent from '@testing-library/user-event'; import { render, screen} from '@testing-library/react'; import Counter from '../src/components/components'; diff --git a/test/components/Logging/Filters.test.tsx b/test/components/Logging/Filters.test.tsx index 9d5e40b..9ba4417 100644 --- a/test/components/Logging/Filters.test.tsx +++ b/test/components/Logging/Filters.test.tsx @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import {render, screen, waitFor, fireEvent} from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import * as React from "react"; diff --git a/test/components/Logging/Logging.test.tsx b/test/components/Logging/Logging.test.tsx index 36692a9..f85a894 100644 --- a/test/components/Logging/Logging.test.tsx +++ b/test/components/Logging/Logging.test.tsx @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import {render, screen, fireEvent, act, waitFor} from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import "@testing-library/jest-dom"; diff --git a/test/components/Logging/useLogs.test.tsx b/test/components/Logging/useLogs.test.tsx index 30a7c2d..d2272b8 100644 --- a/test/components/Logging/useLogs.test.tsx +++ b/test/components/Logging/useLogs.test.tsx @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import { render, screen, act } from "@testing-library/react"; import "@testing-library/jest-dom"; import {type LogRecord, useLogs} from "../../../src/components/Logging/useLogs.ts"; diff --git a/test/eslint.config.js.ts b/test/eslint.config.js.ts index e69de29..8bae862 100644 --- a/test/eslint.config.js.ts +++ b/test/eslint.config.js.ts @@ -0,0 +1,3 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) \ No newline at end of file diff --git a/test/pages/connectedRobots/ConnectedRobots.test.tsx b/test/pages/connectedRobots/ConnectedRobots.test.tsx index 017b2a2..e5b1fc3 100644 --- a/test/pages/connectedRobots/ConnectedRobots.test.tsx +++ b/test/pages/connectedRobots/ConnectedRobots.test.tsx @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import { render, screen, act, cleanup, waitFor } from '@testing-library/react'; import ConnectedRobots from '../../../src/pages/ConnectedRobots/ConnectedRobots'; diff --git a/test/pages/monitoringPage/MonitoringPage.test.tsx b/test/pages/monitoringPage/MonitoringPage.test.tsx index 482372a..87f1f30 100644 --- a/test/pages/monitoringPage/MonitoringPage.test.tsx +++ b/test/pages/monitoringPage/MonitoringPage.test.tsx @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import { render, screen, fireEvent, act } from '@testing-library/react'; import '@testing-library/jest-dom'; import MonitoringPage from '../../../src/pages/MonitoringPage/MonitoringPage'; @@ -27,7 +30,7 @@ jest.mock('../../../src/pages/MonitoringPage/MonitoringPageAPI', () => ({ // Mock VisProg functionality jest.mock('../../../src/pages/VisProgPage/VisProgLogic', () => ({ graphReducer: jest.fn(), - runProgramm: jest.fn(), + runProgram: jest.fn(), })); // Mock Child Components to reduce noise (optional, but keeps unit test focused) @@ -160,12 +163,12 @@ describe('MonitoringPage', () => { expect(VisProg.graphReducer).toHaveBeenCalled(); expect(mockSetProgramState).toHaveBeenCalledWith({ phases: [{ id: 'new-phase' }] }); - expect(VisProg.runProgramm).toHaveBeenCalled(); + expect(VisProg.runProgram).toHaveBeenCalled(); }); test('Reset Experiment handles errors gracefully', async () => { const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); - (VisProg.runProgramm as jest.Mock).mockRejectedValue(new Error('Fail')); + (VisProg.runProgram as jest.Mock).mockRejectedValue(new Error('Fail')); render(); await act(async () => { diff --git a/test/pages/monitoringPage/MonitoringPageAPI.test.ts b/test/pages/monitoringPage/MonitoringPageAPI.test.ts index 01a21b7..19afbdc 100644 --- a/test/pages/monitoringPage/MonitoringPageAPI.test.ts +++ b/test/pages/monitoringPage/MonitoringPageAPI.test.ts @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import { renderHook, act, cleanup } from '@testing-library/react'; import { sendAPICall, diff --git a/test/pages/monitoringPage/MonitoringPageComponents.test.tsx b/test/pages/monitoringPage/MonitoringPageComponents.test.tsx index f454fe1..992e3bd 100644 --- a/test/pages/monitoringPage/MonitoringPageComponents.test.tsx +++ b/test/pages/monitoringPage/MonitoringPageComponents.test.tsx @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import React from 'react'; import { render, screen, fireEvent, act } from '@testing-library/react'; import '@testing-library/jest-dom'; diff --git a/test/pages/robot/Robot.test.tsx b/test/pages/robot/Robot.test.tsx index bcebac8..2e872c7 100644 --- a/test/pages/robot/Robot.test.tsx +++ b/test/pages/robot/Robot.test.tsx @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import { render, screen, act, cleanup, fireEvent } from '@testing-library/react'; import Robot from '../../../src/pages/Robot/Robot'; diff --git a/test/pages/simpleProgram/SimpleProgram.tsx b/test/pages/simpleProgram/SimpleProgram.tsx deleted file mode 100644 index 22fcbbf..0000000 --- a/test/pages/simpleProgram/SimpleProgram.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { render, screen, fireEvent } from "@testing-library/react"; -import SimpleProgram from "../../../src/pages/SimpleProgram/SimpleProgram"; -import useProgramStore from "../../../src/utils/programStore"; - -/** - * Helper to preload the program store before rendering. - */ -function loadProgram(phases: Record[]) { - useProgramStore.getState().setProgramState({ phases }); -} - -describe("SimpleProgram", () => { - beforeEach(() => { - loadProgram([]); - }); - - test("shows empty state when no program is loaded", () => { - render(); - expect(screen.getByText("No program loaded.")).toBeInTheDocument(); - }); - - test("renders first phase content", () => { - loadProgram([ - { - id: "phase-1", - norms: [{ id: "n1", norm: "Be polite" }], - goals: [{ id: "g1", description: "Finish task", achieved: true }], - triggers: [{ id: "t1", label: "Keyword trigger" }], - }, - ]); - - render(); - - expect(screen.getByText("Phase 1 / 1")).toBeInTheDocument(); - expect(screen.getByText("Be polite")).toBeInTheDocument(); - expect(screen.getByText("Finish task")).toBeInTheDocument(); - expect(screen.getByText("Keyword trigger")).toBeInTheDocument(); - }); - - test("allows navigating between phases", () => { - loadProgram([ - { - id: "phase-1", - norms: [], - goals: [], - triggers: [], - }, - { - id: "phase-2", - norms: [{ id: "n2", norm: "Be careful" }], - goals: [], - triggers: [], - }, - ]); - - render(); - - expect(screen.getByText("Phase 1 / 2")).toBeInTheDocument(); - - fireEvent.click(screen.getByText("Next ▶")); - - expect(screen.getByText("Phase 2 / 2")).toBeInTheDocument(); - expect(screen.getByText("Be careful")).toBeInTheDocument(); - }); - - test("prev button is disabled on first phase", () => { - loadProgram([ - { id: "phase-1", norms: [], goals: [], triggers: [] }, - ]); - - render(); - expect(screen.getByText("◀ Prev")).toBeDisabled(); - }); - - test("next button is disabled on last phase", () => { - loadProgram([ - { id: "phase-1", norms: [], goals: [], triggers: [] }, - ]); - - render(); - expect(screen.getByText("Next ▶")).toBeDisabled(); - }); -}); diff --git a/test/pages/visProgPage/visualProgrammingUI/EditorUndoRedo.test.ts b/test/pages/visProgPage/visualProgrammingUI/EditorUndoRedo.test.ts index 39b459d..fc203d1 100644 --- a/test/pages/visProgPage/visualProgrammingUI/EditorUndoRedo.test.ts +++ b/test/pages/visProgPage/visualProgrammingUI/EditorUndoRedo.test.ts @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import {act} from '@testing-library/react'; import useFlowStore from '../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx'; import { mockReactFlow } from '../../../setupFlowTests.ts'; diff --git a/test/pages/visProgPage/visualProgrammingUI/GraphReducer.test.ts b/test/pages/visProgPage/visualProgrammingUI/GraphReducer.test.ts index 192a7cf..034c8b3 100644 --- a/test/pages/visProgPage/visualProgrammingUI/GraphReducer.test.ts +++ b/test/pages/visProgPage/visualProgrammingUI/GraphReducer.test.ts @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) describe('not yet implemented', () => { test('nothing yet', () => { expect(true); diff --git a/test/pages/visProgPage/visualProgrammingUI/HandleRuleLogic.test.ts b/test/pages/visProgPage/visualProgrammingUI/HandleRuleLogic.test.ts index fbeddb1..16f1a56 100644 --- a/test/pages/visProgPage/visualProgrammingUI/HandleRuleLogic.test.ts +++ b/test/pages/visProgPage/visualProgrammingUI/HandleRuleLogic.test.ts @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import {renderHook} from "@testing-library/react"; import type {Connection} from "@xyflow/react"; import { diff --git a/test/pages/visProgPage/visualProgrammingUI/HandleRules.test.ts b/test/pages/visProgPage/visualProgrammingUI/HandleRules.test.ts index 65abe19..78496a6 100644 --- a/test/pages/visProgPage/visualProgrammingUI/HandleRules.test.ts +++ b/test/pages/visProgPage/visualProgrammingUI/HandleRules.test.ts @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import {ruleResult} from "../../../../src/pages/VisProgPage/visualProgrammingUI/HandleRuleLogic.ts"; import { allowOnlyConnectionsFromType, diff --git a/test/pages/visProgPage/visualProgrammingUI/VisProgStores.test.tsx b/test/pages/visProgPage/visualProgrammingUI/VisProgStores.test.tsx index d53d1bc..1467076 100644 --- a/test/pages/visProgPage/visualProgrammingUI/VisProgStores.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/VisProgStores.test.tsx @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import {act} from '@testing-library/react'; import { type Connection, diff --git a/test/pages/visProgPage/visualProgrammingUI/components/DragDropSidebar.test.tsx b/test/pages/visProgPage/visualProgrammingUI/components/DragDropSidebar.test.tsx index 559ed68..7697c0e 100644 --- a/test/pages/visProgPage/visualProgrammingUI/components/DragDropSidebar.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/components/DragDropSidebar.test.tsx @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import { getByTestId, render } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores'; diff --git a/test/pages/visProgPage/visualProgrammingUI/components/EditorWarnings.test.tsx b/test/pages/visProgPage/visualProgrammingUI/components/EditorWarnings.test.tsx index 8351c8d..defddc4 100644 --- a/test/pages/visProgPage/visualProgrammingUI/components/EditorWarnings.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/components/EditorWarnings.test.tsx @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import { describe, it, expect} from '@jest/globals'; import { type EditorWarning, warningSummary diff --git a/test/pages/visProgPage/visualProgrammingUI/components/GestureValueEditor.test.tsx b/test/pages/visProgPage/visualProgrammingUI/components/GestureValueEditor.test.tsx index 3bbc205..a171bdd 100644 --- a/test/pages/visProgPage/visualProgrammingUI/components/GestureValueEditor.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/components/GestureValueEditor.test.tsx @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import { useState } from 'react'; import userEvent from '@testing-library/user-event'; import { renderWithProviders, screen } from '../../../../test-utils/test-utils.tsx'; diff --git a/test/pages/visProgPage/visualProgrammingUI/components/NodeComponents.test.tsx b/test/pages/visProgPage/visualProgrammingUI/components/NodeComponents.test.tsx index f7f56ed..c28e0e3 100644 --- a/test/pages/visProgPage/visualProgrammingUI/components/NodeComponents.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/components/NodeComponents.test.tsx @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import { fireEvent, screen } from '@testing-library/react'; import '@testing-library/jest-dom'; import {Tooltip} from "../../../../../src/pages/VisProgPage/visualProgrammingUI/components/NodeComponents.tsx"; diff --git a/test/pages/visProgPage/visualProgrammingUI/components/PlanEditor.test.tsx b/test/pages/visProgPage/visualProgrammingUI/components/PlanEditor.test.tsx index 671a115..0dac0e2 100644 --- a/test/pages/visProgPage/visualProgrammingUI/components/PlanEditor.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/components/PlanEditor.test.tsx @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import { describe, it, beforeEach, jest } from '@jest/globals'; import { screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; diff --git a/test/pages/visProgPage/visualProgrammingUI/components/SaveLoadPanel.test.tsx b/test/pages/visProgPage/visualProgrammingUI/components/SaveLoadPanel.test.tsx index 65458c9..14b3e61 100644 --- a/test/pages/visProgPage/visualProgrammingUI/components/SaveLoadPanel.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/components/SaveLoadPanel.test.tsx @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) // SaveLoadPanel.all.test.tsx import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx'; diff --git a/test/pages/visProgPage/visualProgrammingUI/components/ScrollIntoView.test.tsx b/test/pages/visProgPage/visualProgrammingUI/components/ScrollIntoView.test.tsx index 2a91e85..2182c68 100644 --- a/test/pages/visProgPage/visualProgrammingUI/components/ScrollIntoView.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/components/ScrollIntoView.test.tsx @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import { render } from '@testing-library/react'; import { act } from '@testing-library/react'; import ScrollIntoView from '../../../../../src/components/ScrollIntoView'; diff --git a/test/pages/visProgPage/visualProgrammingUI/components/WarningSidebar.test.tsx b/test/pages/visProgPage/visualProgrammingUI/components/WarningSidebar.test.tsx index 9ccf735..d9a5da0 100644 --- a/test/pages/visProgPage/visualProgrammingUI/components/WarningSidebar.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/components/WarningSidebar.test.tsx @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import {fireEvent, render, screen} from '@testing-library/react'; import '@testing-library/jest-dom'; import {useReactFlow, useStoreApi} from "@xyflow/react"; diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/BeliefGlobals.test.ts b/test/pages/visProgPage/visualProgrammingUI/nodes/BeliefGlobals.test.ts index 54cfa7f..263ed4c 100644 --- a/test/pages/visProgPage/visualProgrammingUI/nodes/BeliefGlobals.test.ts +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/BeliefGlobals.test.ts @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import { describe, it, expect, jest, beforeEach } from '@jest/globals'; import {type Connection, getOutgoers, type Node} from '@xyflow/react'; import {ruleResult} from "../../../../../src/pages/VisProgPage/visualProgrammingUI/HandleRuleLogic.ts"; diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/BeliefNode.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/BeliefNode.test.tsx index 67d80c6..b5592ea 100644 --- a/test/pages/visProgPage/visualProgrammingUI/nodes/BeliefNode.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/BeliefNode.test.tsx @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) // BasicBeliefNode.test.tsx import { describe, it, beforeEach } from '@jest/globals'; import { screen, waitFor } from '@testing-library/react'; diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/GoalNode.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/GoalNode.test.tsx index 96f7fd9..ad75aff 100644 --- a/test/pages/visProgPage/visualProgrammingUI/nodes/GoalNode.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/GoalNode.test.tsx @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import { describe, it, beforeEach } from '@jest/globals'; import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/InferredBeliefNode.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/InferredBeliefNode.test.tsx index d683b23..caeb19d 100644 --- a/test/pages/visProgPage/visualProgrammingUI/nodes/InferredBeliefNode.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/InferredBeliefNode.test.tsx @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import { describe, it, expect, jest, beforeEach } from '@jest/globals'; import type {Node, Edge} from '@xyflow/react'; import * as FlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx'; diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx index 49e5e74..935ea55 100644 --- a/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import { describe, it, beforeEach } from '@jest/globals'; import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/PhaseNode.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/PhaseNode.test.tsx index 327bd8c..b34be4c 100644 --- a/test/pages/visProgPage/visualProgrammingUI/nodes/PhaseNode.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/PhaseNode.test.tsx @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import type { Node, Edge, Connection } from '@xyflow/react' import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores'; import type {PhaseNode, PhaseNodeData} from "../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode"; diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/StartNode.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/StartNode.test.tsx index f1d468d..d405b35 100644 --- a/test/pages/visProgPage/visualProgrammingUI/nodes/StartNode.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/StartNode.test.tsx @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import { describe, it } from '@jest/globals'; import '@testing-library/jest-dom'; import { screen } from '@testing-library/react'; diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/TriggerNode.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/TriggerNode.test.tsx index 43530a2..a152f93 100644 --- a/test/pages/visProgPage/visualProgrammingUI/nodes/TriggerNode.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/TriggerNode.test.tsx @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import { describe, it, beforeEach } from '@jest/globals'; import { screen } from '@testing-library/react'; import { renderWithProviders } from '../../../../test-utils/test-utils.tsx'; diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx index 1aee53a..f07efe1 100644 --- a/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import { describe, beforeEach } from '@jest/globals'; import { screen } from '@testing-library/react'; import { renderWithProviders } from '../../../../test-utils/test-utils.tsx'; diff --git a/test/setupFlowTests.ts b/test/setupFlowTests.ts index caeda94..bcf13e6 100644 --- a/test/setupFlowTests.ts +++ b/test/setupFlowTests.ts @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import '@testing-library/jest-dom'; import { cleanup } from '@testing-library/react'; import { diff --git a/test/setupTests.ts b/test/setupTests.ts index 5cb4da6..01d635a 100644 --- a/test/setupTests.ts +++ b/test/setupTests.ts @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) // Adds jest-dom matchers for React testing library import '@testing-library/jest-dom'; diff --git a/test/test-utils/mocks.ts b/test/test-utils/mocks.ts index 21971c1..8f5f0b8 100644 --- a/test/test-utils/mocks.ts +++ b/test/test-utils/mocks.ts @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import { jest } from '@jest/globals'; import React from 'react'; import '@testing-library/jest-dom'; diff --git a/test/test-utils/test-utils.tsx b/test/test-utils/test-utils.tsx index 157ea19..1f51702 100644 --- a/test/test-utils/test-utils.tsx +++ b/test/test-utils/test-utils.tsx @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) // __tests__/utils/test-utils.tsx import { render, type RenderOptions } from '@testing-library/react'; import { type ReactElement, type ReactNode } from 'react'; diff --git a/test/utils/cellStore.test.tsx b/test/utils/cellStore.test.tsx index 96460b8..ec28c59 100644 --- a/test/utils/cellStore.test.tsx +++ b/test/utils/cellStore.test.tsx @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import {render, screen, act} from "@testing-library/react"; import "@testing-library/jest-dom"; import {type Cell, cell, useCell} from "../../src/utils/cellStore.ts"; diff --git a/test/utils/duplicateIndices.test.ts b/test/utils/duplicateIndices.test.ts index 25dce1a..6fbc703 100644 --- a/test/utils/duplicateIndices.test.ts +++ b/test/utils/duplicateIndices.test.ts @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import duplicateIndices from "../../src/utils/duplicateIndices.ts"; describe("duplicateIndices (unit)", () => { diff --git a/test/utils/formatDuration.test.ts b/test/utils/formatDuration.test.ts index b686a43..7a59b61 100644 --- a/test/utils/formatDuration.test.ts +++ b/test/utils/formatDuration.test.ts @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import formatDuration from "../../src/utils/formatDuration.ts"; describe("formatting durations (unit)", () => { diff --git a/test/utils/orderPhaseNodes.test.ts b/test/utils/orderPhaseNodes.test.ts index 5020378..cff6edc 100644 --- a/test/utils/orderPhaseNodes.test.ts +++ b/test/utils/orderPhaseNodes.test.ts @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import type {PhaseNode} from "../../src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx"; import orderPhaseNodeArray from "../../src/utils/orderPhaseNodes.ts"; diff --git a/test/utils/priorityFiltering.test.ts b/test/utils/priorityFiltering.test.ts index 6cc8789..a553243 100644 --- a/test/utils/priorityFiltering.test.ts +++ b/test/utils/priorityFiltering.test.ts @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import {applyPriorityPredicates, type PriorityFilterPredicate} from "../../src/utils/priorityFiltering"; const makePred = (priority: number, fn: (el: T) => boolean | null): PriorityFilterPredicate => ({ diff --git a/test/utils/programStore.test.ts b/test/utils/programStore.test.ts index 9865668..e78a98c 100644 --- a/test/utils/programStore.test.ts +++ b/test/utils/programStore.test.ts @@ -1,3 +1,6 @@ +// This program has been developed by students from the bachelor Computer Science at Utrecht +// University within the Software Project course. +// © Copyright Utrecht University (Department of Information and Computing Sciences) import useProgramStore, {type ReducedProgram} from "../../src/utils/programStore.ts"; -- 2.49.1