88 Commits
main ... demo

Author SHA1 Message Date
JGerla
48c800eb2f Merge branch 'feat/editor-user-feedback' into demo 2026-01-20 12:55:35 +01:00
JGerla
327d1de621 feat: Added visual separation between global and node or handle specific warnings
ref: N25B-450
2026-01-20 12:54:47 +01:00
JGerla
3f6d95683d feat: Added visual separation between global and node or handle specific warnings
ref: N25B-450
2026-01-20 12:50:29 +01:00
Twirre Meulenbelt
633590e247 Merge remote-tracking branch 'origin/demo' into demo 2026-01-20 12:42:01 +01:00
Pim Hutting
e4ac063322 chore: now it works 2026-01-20 12:41:27 +01:00
Twirre Meulenbelt
ca5d50b824 Merge branch 'feat/experiment-logs' into demo 2026-01-20 12:39:41 +01:00
Pim Hutting
61b4afa4de Merge branch 'feat/monitoringpage-pim' into demo 2026-01-20 12:37:34 +01:00
Pim Hutting
a8f9965391 chore: added recursive goals to monitor page 2026-01-20 12:31:34 +01:00
JGerla
6da7e12138 Merge branch 'feat/editor-user-feedback' into demo
# Conflicts:
#	src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx
2026-01-20 12:14:54 +01:00
JGerla
5d55ebaaa2 feat: Added global warning for incomplete program chain
ref: N25B-450
2026-01-20 11:53:51 +01:00
JGerla
23a02b2b4a Merge branch 'demo' into feat/editor-user-feedback 2026-01-20 11:13:22 +01:00
JGerla
487ee30923 feat: made jumpToNode select the node after focussing the editor
ref: N25B-450
2026-01-20 10:22:08 +01:00
Pim Hutting
2ca0c9c4c0 chore: added Storms change 2026-01-19 18:17:47 +01:00
JGerla
7c9c0e1581 Merge branch 'demo' into feat/add-inferred-belief-node
# Conflicts:
#	src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx
#	src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx
#	src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx
2026-01-19 17:19:28 +01:00
JGerla
0ba026092d fix: fixed reconnect being able to circumvent connection rules
the fix fixes the behavior, however user feedback does not reflect the fix as it driven by events that aren't triggered by onReconnect connection, and thus it would take a lot of time to fix the user feedback for onReconnect
2026-01-19 17:13:46 +01:00
Twirre Meulenbelt
6d5f1e33ef feat: add experiment logs to the monitoring page
ref: N25B-401
2026-01-19 12:57:01 +01:00
Pim Hutting
dcc50fd978 feat: implemented basic version of reset phase
right now reset phase also clears LLM

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

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

ref: N25B-400
2026-01-16 12:57:22 +01:00
JGerla
5a9b78fdda feat: added jump to node on clicking the warning
ref: N25B-450
2026-01-15 16:24:08 +01:00
JGerla
a6f24b677f feat: added two new warnings
ref: N25B-450
2026-01-15 16:09:24 +01:00
JGerla
022a6708ea feat: added a Warnings sidebar
warnings are now displayed in the sidebar

ref: N25B-450
2026-01-15 16:04:09 +01:00
JGerla
f62f416af3 Merge remote-tracking branch 'origin/feat/editor-user-feedback' into feat/editor-user-feedback
# Conflicts:
#	src/pages/VisProgPage/VisProg.tsx
2026-01-15 14:23:55 +01:00
JGerla
385ec250cc feat: finished basic warning system
nodes can now register warnings to prevent running the program

ref: N25B-450
2026-01-15 14:23:35 +01:00
JGerla
35bf3ad9e5 feat: finished basic warning system
nodes can now register warnings to prevent running the program

ref: N25B-450
2026-01-15 14:22:50 +01:00
JGerla
66daafe1f0 feat: added disabling of runProgram button if program is not valid
ref: N25B-450
2026-01-15 12:21:09 +01:00
JGerla
5d650b36ce feat: implemented the rest of the warning registry
ref: N25B-450
2026-01-15 12:15:32 +01:00
Pim Hutting
a98a87f8ce refactor: renamed Components.tsx
renamed it to MonitoringPageComponents.tsx

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

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

ref: N25B-400
2026-01-15 09:04:32 +01:00
JGerla
e9acab456e feat: implemented getWarningsBySeverity
ref: N25B-450
2026-01-14 16:59:28 +01:00
JGerla
1a8670ba13 feat: implemented delete all warnings for a node
ref: N25B-450
2026-01-14 16:52:43 +01:00
JGerla
f174623a4c feat: implemented basic add and remove functions
ref: N25B-450
2026-01-14 16:46:50 +01:00
JGerla
b3b77b94ad feat: slightly modified structure for better global logic
ref: N25B-450
2026-01-14 16:11:48 +01:00
JGerla
67558a7ac7 feat: added full definition of editor warning infrastructure
Everything is now defined architecturally, and can be implemented properly.

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

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

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

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

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

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

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

ref: N25B-428
2025-12-20 22:58:22 +01:00
57 changed files with 4401 additions and 168 deletions

16
package-lock.json generated
View File

@@ -10,6 +10,7 @@
"dependencies": {
"@neodrag/react": "^2.3.1",
"@xyflow/react": "^12.8.6",
"clsx": "^2.1.1",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-router": "^7.9.3",
@@ -3971,6 +3972,15 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/co": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
@@ -6945,9 +6955,9 @@
}
},
"node_modules/react-router": {
"version": "7.9.3",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.3.tgz",
"integrity": "sha512-4o2iWCFIwhI/eYAIL43+cjORXYn/aRQPgtFRRZb3VzoyQ5Uej0Bmqj7437L97N9NJW4wnicSwLOLS+yCXfAPgg==",
"version": "7.12.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.12.0.tgz",
"integrity": "sha512-kTPDYPFzDVGIIGNLS5VJykK0HfHLY5MF3b+xj0/tTyNYL1gF1qs7u67Z9jEhQk2sQ98SUaHxlG31g1JtF7IfVw==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",

View File

@@ -14,6 +14,7 @@
"dependencies": {
"@neodrag/react": "^2.3.1",
"@xyflow/react": "^12.8.6",
"clsx": "^2.1.1",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-router": "^7.9.3",

View File

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

View File

@@ -8,6 +8,7 @@ import VisProg from "./pages/VisProgPage/VisProg.tsx";
import {useState} from "react";
import Logging from "./components/Logging/Logging.tsx";
function App(){
const [showLogs, setShowLogs] = useState(false);
@@ -15,7 +16,7 @@ function App(){
<>
<header>
<Link to={"/"}>Home</Link>
<button onClick={() => setShowLogs(!showLogs)}>Toggle Logging</button>
<button onClick={() => setShowLogs(!showLogs)}>Developer Logs</button>
</header>
<div className={"flex-row justify-center flex-1 min-height-0"}>
<main className={"flex-col align-center flex-1 scroll-y"}>

48
src/components/Dialog.tsx Normal file
View File

@@ -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<HTMLDialogElement | null> = 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 (
<dialog
ref={ref}
onCancel={close}
onPointerDown={handleClickOutside}
className={classname}
>
{children}
</dialog>
);
}

View File

@@ -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<LogRecord>,
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";

View File

@@ -13,9 +13,8 @@ type Setter<T> = (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<string, number> = new Map([
["ALL", 0],
["LLM", 9],
["DEBUG", 10],
["INFO", 20],
["WARNING", 30],
@@ -93,7 +92,7 @@ function GlobalLevelFilter({
filterPredicates: Map<string, LogFilterPredicate>;
setFilterPredicates: Setter<Map<string, LogFilterPredicate>>;
}) {
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;

View File

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

View File

@@ -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<LoggingSettings>((set) => ({
showRelativeTime: false,
setShowRelativeTime: (showRelativeTime: boolean) => set({ showRelativeTime }),
scrollToBottom: true,
setScrollToBottom: (scrollToBottom: boolean) => set({ scrollToBottom }),
}));
/**
@@ -45,13 +30,7 @@ const useLoggingSettings = create<LoggingSettings>((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<LogRecord>,
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 <div className={`${styles.logContainer} round-md border-lg flex-row gap-md`}>
<div className={`${styles[`accented${normalizedLevelNo}`]} flex-col padding-sm justify-between`}>
<span className={"mono bold"}>{record.levelname}</span>
<span className={"mono clickable font-small"}
onClick={() => setShowRelativeTime(!showRelativeTime)}
>{showRelativeTime
? formatDuration(record.relativeCreated)
: new Date(record.created * 1000).toLocaleTimeString()
<span className={"mono clickable font-small"} onClick={() => setShowRelativeTime(!showRelativeTime)}>{
showRelativeTime
? formatDuration(record.relativeCreated)
: new Date(record.created * 1000).toLocaleTimeString()
}</span>
</div>
<div className={"flex-col flex-1 padding-sm"}>
@@ -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<LogRecord>[] }) {
export function LogMessages({
recordCells,
MessageComponent,
}: {
recordCells: Cell<LogRecord>[],
MessageComponent: ComponentType<MessageComponentProps>,
}) {
const scrollableRef = useRef<HTMLDivElement>(null);
const lastElementRef = useRef<HTMLLIElement>(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<LogRecord>[] }) {
}, [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 <div ref={scrollableRef} className={"min-height-0 scroll-y padding-b-md"}>
return <div ref={scrollableRef} className={"min-height-0 scroll-y padding-h-lg padding-b-lg flex-1"}>
<ol className={`${styles.noNumbers} margin-0 flex-col gap-md`}>
{recordCells.map((recordCell, i) => (
<li key={`${i}_${recordCell.get().firstRelativeCreated}`}>
<LogMessage recordCell={recordCell} onUpdate={scrollLastElementIntoView} />
<MessageComponent recordCell={recordCell} onUpdate={showBottom} />
</li>
))}
<li ref={lastElementRef}></li>
</ol>
{!scrollToBottom && <button
className={styles.floatingButton}
onClick={() => {
setScrollToBottom(true);
scrollLastElementIntoView(true);
showBottom(true);
}}
>
Scroll to bottom
@@ -164,16 +146,27 @@ function LogMessages({ recordCells }: { recordCells: Cell<LogRecord>[] }) {
* - 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.
* 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<string, LogFilterPredicate>());
// By default, filter experiment logs from this debug logger
const [filterPredicates, setFilterPredicates] = useState(new Map<string, LogFilterPredicate>([
[
EXPERIMENT_FILTER_KEY,
{
predicate: (r) => r.name == EXPERIMENT_LOGGER_NAME ? false : null,
priority: 999,
value: null,
} as LogFilterPredicate,
],
]));
const { filteredLogs, distinctNames } = useLogs(filterPredicates)
distinctNames.delete(EXPERIMENT_LOGGER_NAME);
return <div className={`flex-col gap-lg min-height-0 ${styles.loggingContainer}`}>
<div className={"flex-row gap-lg justify-between align-center"}>
return <div className={`flex-col min-height-0 relative ${styles.loggingContainer}`}>
<div className={"flex-row gap-lg justify-between align-center padding-lg"}>
<h2 className={"margin-0"}>Logs</h2>
<Filters
filterPredicates={filterPredicates}
@@ -181,6 +174,6 @@ export default function Logging() {
agentNames={distinctNames}
/>
</div>
<LogMessages recordCells={filteredLogs} />
<LogMessages recordCells={filteredLogs} MessageComponent={LogMessage} />
</div>;
}

View File

@@ -3,6 +3,21 @@ import {useCallback, useEffect, useRef, useState} from "react";
import {applyPriorityPredicates, type PriorityFilterPredicate} from "../../utils/priorityFiltering.ts";
import {cell, type Cell} from "../../utils/cellStore.ts";
type ExtraLevelName = 'LLM' | 'OBSERVATION' | 'ACTION' | 'CHAT';
export type LevelName = ExtraLevelName | 'DEBUG' | 'INFO' | 'WARNING' | 'ERROR' | 'CRITICAL' | string;
/**
* Extra fields that are added to log records in the backend but are not part of the standard `LogRecord` type.
*
* @property reference - (Optional) A reference identifier linking related log messages.
* @property role - (Optional) For chat log messages, the role of the agent that generated the message.
*/
type ExtraLogRecordFields = {
reference?: string;
role?: string;
}
/**
* Represents a single log record emitted by the backend logging system.
*
@@ -12,21 +27,19 @@ import {cell, type Cell} from "../../utils/cellStore.ts";
* @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;
levelname: 'LLM' | 'DEBUG' | 'INFO' | 'WARNING' | 'ERROR' | 'CRITICAL' | string;
levelname: LevelName;
levelno: number;
created: number;
relativeCreated: number;
reference?: string;
firstCreated: number;
firstRelativeCreated: number;
};
} & ExtraLogRecordFields;
/**
* A log filter predicate with priority support, used to determine whether
@@ -37,7 +50,7 @@ export type LogRecord = {
*
* @template T - The type of record being filtered (here, `LogRecord`).
*/
export type LogFilterPredicate = PriorityFilterPredicate<LogRecord> & {
export type LogFilterPredicate = PriorityFilterPredicate<LogRecord> & {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
value: any };

View File

@@ -8,6 +8,9 @@
background-color: #242424;
--accent-color: #008080;
--panel-shadow:
0 1px 2px white,
0 8px 24px rgba(190, 186, 186, 0.253);
font-synthesis: none;
text-rendering: optimizeLegibility;
@@ -15,6 +18,14 @@
-moz-osx-font-smoothing: grayscale;
}
@media (prefers-color-scheme: dark) {
:root {
--panel-shadow:
0 1px 2px rgba(221, 221, 221, 0.178),
0 8px 24px rgba(27, 27, 27, 0.507);
}
}
html, body, #root {
margin: 0;
padding: 0;
@@ -41,7 +52,7 @@ button {
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
background-color: canvas;
cursor: pointer;
transition: border-color 0.25s;
}
@@ -64,9 +75,6 @@ button:focus-visible {
--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) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<LoggingSettings>((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 <div className={`${styles.chatMessage} ${reverse} flex-col padding-md padding-h-lg shadow-md round-md`}>
<div className={`${styles.messageHead} flex-row gap-md align-center`}>
<span className={"bold"}>{capitalize(record.role ?? "unknown")}</span>
<span className={"font-small"}></span>
<span className={"mono clickable font-small"} onClick={() => setShowRelativeTime(!showRelativeTime)}>{
showRelativeTime
? formatDuration(record.relativeCreated)
: new Date(record.created * 1000).toLocaleTimeString()
}</span>
</div>
<span>{record.message}</span>
</div>
}
/**
* 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 <div>
<div className={"flex-row gap-md align-center"}>
<span className={"font-small"}>{record.levelname}</span>
<span className={"font-small"}></span>
<span className={"mono clickable font-small"} onClick={() => setShowRelativeTime(!showRelativeTime)}>{
showRelativeTime
? formatDuration(record.relativeCreated)
: new Date(record.created * 1000).toLocaleTimeString()
}</span>
</div>
<span>{record.message}</span>
</div>;
}
/**
* 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 <ChatMessage record={record} />
} else {
return <DefaultMessage record={record} />
}
}
/**
* 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 <div className={`${styles.downloadList} flex-col align-center justify-center`}>
<p>Loading...</p>
</div>;
if (filenames.length === 0) return <div className={`${styles.downloadList} flex-col align-center justify-center`}>
<p>No files available.</p>
</div>
return <ol className={`${styles.downloadList} margin-0 padding-h-lg scroll-y`}>
{filenames!.map((filename) => (
<li><a key={filename} href={`http://localhost:8000/api/logs/files/${filename}`} download={filename}>{filename}</a></li>
))}
</ol>;
})();
return <div className={"flex-col"}>
<p className={"margin-lg"}>Select a file to download:</p>
{list}
<button onClick={refresh} className={"margin-lg shadow-sm"}>Refresh</button>
</div>;
}
/**
* A button that opens a download dialog for experiment logs when pressed.
*/
function DownloadButton() {
const [showModal, setShowModal] = useState(false);
const [filenames, setFilenames] = useState<string[] | null>(null);
async function getFiles(): Promise<string[]> {
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 <>
<button className={"shadow-sm"} onClick={() => setShowModal((curr) => !curr)}>Download...</button>
<Dialog open={showModal} close={() => setShowModal(false)} classname={"padding-0 round-lg"}>
<DownloadScreen filenames={filenames} refresh={async () => {
setFilenames(null);
const files = await delayedResolve(getFiles(), 250);
setFilenames(files);
}} />
</Dialog>
</>;
}
/**
* 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<string, LogFilterPredicate>([
[
EXPERIMENT_FILTER_KEY,
{
predicate: (r) => r.name == EXPERIMENT_LOGGER_NAME,
priority: 999,
value: null,
} as LogFilterPredicate,
],
]), []);
const { filteredLogs } = useLogs(filters);
return <aside className={`${styles.logs} flex-col relative`}>
<div className={`${styles.head} padding-lg`}>
<div className={"flex-row align-center justify-between"}>
<h3>Logs</h3>
<div className={"flex-row gap-md align-center"}>
<div className={`flex-row align-center gap-md relative padding-md shadow-sm round-md`}>
<div className={styles.live}></div>
<span>Live</span>
</div>
<DownloadButton />
</div>
</div>
</div>
<LogMessages recordCells={filteredLogs} MessageComponent={ExperimentMessage} />
</aside>;
}

View File

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

View File

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

View File

@@ -84,7 +84,10 @@
filter: drop-shadow(0 0 0.25rem plum);
}
.node-inferred_belief {
outline: mediumpurple solid 2pt;
filter: drop-shadow(0 0 0.25rem mediumpurple);
}
.draggable-node {
padding: 3px 10px;
@@ -158,6 +161,14 @@
filter: drop-shadow(0 0 0.25rem plum);
}
.draggable-node-inferred_belief {
padding: 3px 10px;
background-color: canvas;
border-radius: 5pt;
outline: mediumpurple solid 2pt;
filter: drop-shadow(0 0 0.25rem mediumpurple);
}
.planNoIterate {
opacity: 0.5;
font-style: italic;
@@ -172,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;

View File

@@ -4,20 +4,23 @@ import {
Panel,
ReactFlow,
ReactFlowProvider,
MarkerType,
MarkerType, getOutgoers
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import {type CSSProperties, useEffect, useState} from "react";
import {useShallow} from 'zustand/react/shallow';
import orderPhaseNodeArray from "../../utils/orderPhaseNodes.ts";
import useProgramStore from "../../utils/programStore.ts";
import {DndToolbar} from './visualProgrammingUI/components/DragDropSidebar.tsx';
import {type EditorWarning, globalWarning} from "./visualProgrammingUI/components/EditorWarnings.tsx";
import {WarningsSidebar} from "./visualProgrammingUI/components/WarningSidebar.tsx";
import type {PhaseNode} from "./visualProgrammingUI/nodes/PhaseNode.tsx";
import useFlowStore from './visualProgrammingUI/VisProgStores.tsx';
import type {FlowState} from './visualProgrammingUI/VisProgTypes.tsx';
import styles from './VisProg.module.css'
import { 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 |--
@@ -89,9 +92,31 @@ const VisProgUI = () => {
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
});
const {unregisterWarning, registerWarning} = useFlowStore();
useEffect(() => {
if (checkPhaseChain()) {
unregisterWarning(globalWarning,'INCOMPLETE_PROGRAM');
} else {
// create global warning for incomplete program chain
const incompleteProgramWarning : EditorWarning = {
scope: {
id: globalWarning,
handleId: undefined
},
type: 'INCOMPLETE_PROGRAM',
severity: "ERROR",
description: "there is no complete phase chain from the startNode to the EndNode"
}
registerWarning(incompleteProgramWarning);
}
},[edges, registerWarning, unregisterWarning])
return (
<div className={`${styles.innerEditorContainer} round-lg border-lg`} style={({'--flow-zoom': zoom} as CSSProperties)}>
<div className={`${styles.innerEditorContainer} round-lg border-lg flex-row`} style={({'--flow-zoom': zoom} as CSSProperties)}>
<ReactFlow
nodes={nodes}
edges={edges}
@@ -111,6 +136,7 @@ const VisProgUI = () => {
snapToGrid
fitView
proOptions={{hideAttribution: true}}
style={{flexGrow: 3}}
>
<Panel position="top-center" className={styles.dndPanel}>
<DndToolbar/> {/* contains the drag and drop panel for nodes */}
@@ -120,11 +146,13 @@ const VisProgUI = () => {
</Panel>
<Panel position="bottom-center">
<button onClick={() => undo()}>undo</button>
<button onClick={() => redo()}>Redo</button>
</Panel>
<Controls/>
<Background/>
</ReactFlow>
<WarningsSidebar/>
</div>
);
};
@@ -144,40 +172,24 @@ 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."));
}
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);
console.log(next);
return !!next;
}
return checkForCompleteChain('start');
};
/**
@@ -186,10 +198,40 @@ function graphReducer() {
* @constructor
*/
function VisProgPage() {
const [showSimpleProgram, setShowSimpleProgram] = useState(false);
const setProgramState = useProgramStore((state) => state.setProgramState);
const runProgram = () => {
const phases = graphReducer(); // reduce graph
setProgramState({ phases }); // <-- save to store
setShowSimpleProgram(true); // show SimpleProgram
runProgramm(); // send to backend if needed
};
if (showSimpleProgram) {
return (
<div>
<button className={styles.backButton} onClick={() => setShowSimpleProgram(false)}>
Back to Editor
</button>
<MonitoringPage/>
</div>
);
}
const [programValidity, setProgramValidity] = useState<boolean>(true);
const {isProgramValid, severityIndex} = useFlowStore();
useEffect(() => {
setProgramValidity(isProgramValid);
// 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]);
return (
<>
<VisualProgrammingUI/>
<button onClick={runProgram}>run program</button>
<button onClick={runProgram} disabled={!programValidity}>run program</button>
</>
)
}

View File

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

View File

@@ -107,4 +107,16 @@ export function useHandleRules(
// finally we return a function that evaluates all rules using the created context
return evaluateRules(targetRules, connection, context);
};
}
export function validateConnectionWithRules(
connection: Connection,
context: ConnectionContext
): RuleResult {
const rules = useFlowStore.getState().getTargetRules(
connection.target!,
connection.targetHandle!
);
return evaluateRules(rules,connection, context);
}

View File

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

View File

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

View File

@@ -9,6 +9,8 @@ import {
type XYPosition,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import {type ConnectionContext, validateConnectionWithRules} from "./HandleRuleLogic.ts";
import {editorWarningRegistry} from "./components/EditorWarnings.tsx";
import type { FlowState } from './VisProgTypes';
import {
NodeDefaults,
@@ -49,7 +51,7 @@ function createNode(id: string, type: string, position: XYPosition, data: Record
const endNode = createNode('end', 'end', {x: 590, y: 100}, {label: "End"}, false)
const initialPhaseNode = createNode(crypto.randomUUID(), 'phase', {x:235, y:100}, {label: "Phase 1", children : [], isFirstPhase: false, nextPhaseId: null})
const initialNodes : Node[] = [startNode, endNode, initialPhaseNode,];
const initialNodes : Node[] = [startNode, endNode, initialPhaseNode];
// Initial edges, leave empty as setting initial edges...
// ...breaks logic that is dependent on connection events
@@ -129,7 +131,41 @@ const useFlowStore = create<FlowState>(UndoRedo((set, get) => ({
* Handles reconnecting an edge between nodes.
*/
onReconnect: (oldEdge, newConnection) => {
get().edgeReconnectSuccessful = true;
function createContext(
source: {id: string, handleId: string},
target: {id: string, handleId: string}
) : ConnectionContext {
const edges = get().edges;
const targetConnections = edges.filter(edge => edge.target === target.id && edge.targetHandle === target.handleId).length
return {
connectionCount: targetConnections,
source: source,
target: target
}
}
// connection validation
const context: ConnectionContext = oldEdge.source === newConnection.source
? createContext({id: newConnection.source, handleId: newConnection.sourceHandle!}, {id: newConnection.target, handleId: newConnection.targetHandle!})
: createContext({id: newConnection.target, handleId: newConnection.targetHandle!}, {id: newConnection.source, handleId: newConnection.sourceHandle!});
const result = validateConnectionWithRules(
newConnection,
context
);
if (!result.isSatisfied) {
set({
edges: get().edges.map(e =>
e.id === oldEdge.id ? oldEdge : e
),
});
return;
}
// further reconnect logic
set({ edgeReconnectSuccessful: true });
set({ edges: reconnectEdge(oldEdge, newConnection, get().edges) });
// We make sure to perform any required data updates on the newly reconnected nodes
@@ -188,7 +224,7 @@ const useFlowStore = create<FlowState>(UndoRedo((set, get) => ({
// Let's find our node to check if they have a special deletion function
const ourNode = get().nodes.find((n)=>n.id==nodeId);
const ourFunction = Object.entries(NodeDeletes).find(([t])=>t==ourNode?.type)?.[1]
// If there's no function, OR, our function tells us we can delete it, let's do so...
if (ourFunction == undefined || ourFunction()) {
set({
@@ -306,8 +342,12 @@ const useFlowStore = create<FlowState>(UndoRedo((set, get) => ({
})
return { ruleRegistry: registry };
})
}
},
...editorWarningRegistry(get, set),
}))
);
export default useFlowStore;

View File

@@ -9,6 +9,7 @@ import type {
OnEdgesDelete,
OnNodesDelete
} from '@xyflow/react';
import type {EditorWarningRegistry} from "./components/EditorWarnings.tsx";
import type {HandleRule} from "./HandleRuleLogic.ts";
import type { NodeTypes } from './NodeRegistry';
import type {FlowSnapshot} from "./EditorUndoRedo.ts";
@@ -94,7 +95,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 +130,7 @@ export type HandleRuleRegistry = {
// cleans up all registered rules of all handles of the provided node
unregisterNodeRules: (nodeId: string) => void
}
}

View File

@@ -0,0 +1,217 @@
/* 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'
| 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
export type WarningScope = {
id: string;
handleId?: string;
}
export type EditorWarning = {
scope: WarningScope;
type: WarningType;
severity: WarningSeverity;
description: string;
};
/**
* a scoped WarningKey,
* the handleId scoping is only needed for handle specific errors
*
* "`WarningType`:`handleId`"
*/
export type WarningKey = string; // for warnings that can occur on a per-handle basis
/**
* a composite key used in the severityIndex
*
* "`WarningId`|`WarningKey`"
*/
export type CompositeWarningKey = string;
export type WarningRegistry = Map<WarningId , Map<WarningKey, EditorWarning>>;
export type SeverityIndex = Map<WarningSeverity, Set<CompositeWarningKey>>;
type ZustandSet = (partial: Partial<FlowState> | ((state: FlowState) => Partial<FlowState>)) => void;
type ZustandGet = () => FlowState;
export type EditorWarningRegistry = {
editorWarningRegistry: WarningRegistry;
severityIndex: SeverityIndex;
getWarnings: () => 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 {EditorWarning} warning
*/
unregisterWarningsForId: (id: WarningId) => void;
}
// --| implemented logic |--
export const globalWarning = "GLOBAL_WARNINGS";
export function editorWarningRegistry(get: ZustandGet, set: ZustandSet) : EditorWarningRegistry { return {
editorWarningRegistry: new Map<NodeId, Map<WarningKey, EditorWarning>>(),
severityIndex: new Map([
['INFO', new Set<CompositeWarningKey>()],
['WARNING', new Set<CompositeWarningKey>()],
['ERROR', new Set<CompositeWarningKey>()],
]),
getWarningsBySeverity: (warningSeverity) => {
const wRegistry = get().editorWarningRegistry;
const sIndex = 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 = structuredClone(get().editorWarningRegistry);
const sIndex = structuredClone(get().severityIndex);
console.log("register")
// 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 = structuredClone(get().editorWarningRegistry);
const sIndex = structuredClone(get().severityIndex);
console.log("unregister")
// 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 = structuredClone(get().editorWarningRegistry);
const sIndex = structuredClone(get().severityIndex);
const nodeWarnings = wRegistry.get(id);
// remove from severity index
if (nodeWarnings) {
nodeWarnings.forEach((warning, warningKey) => {
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
export function warningSummary() {
const {severityIndex, isProgramValid} = useFlowStore.getState();
return {
info: severityIndex.get('INFO')!.size,
warning: severityIndex.get('WARNING')!.size,
error: severityIndex.get('ERROR')!.size,
isValid: isProgramValid(),
};
}

View File

@@ -0,0 +1,82 @@
.warnings-sidebar {
width: 320px;
height: 100%;
background: canvas;
border-left: 2px solid black;
display: flex;
flex-direction: column;
}
.warnings-header {
padding: 12px;
border-bottom: 1px solid #2a2a2e;
}
.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;
}
.warning-group-header {
background: ButtonFace;
}
.warnings-list {
flex: 1;
overflow-y: auto;
}
.warnings-empty {
margin: auto;
}
.warning-item {
display: flex;
margin: 5px;
gap: 8px;
padding: 8px 12px;
border-radius: 5px;
cursor: pointer;
}
.warning-item:hover {
background: ButtonFace;
}
.warning-item--error {
border: 2px solid red;
}
.warning-item--warning {
border: 3px solid orange;
}
.warning-item--info {
border: 3px solid steelblue;
}
.warning-item .meta {
font-size: 11px;
opacity: 0.6;
}

View File

@@ -0,0 +1,154 @@
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";
export function WarningsSidebar() {
const warnings = useFlowStore.getState().getWarnings();
const [severityFilter, setSeverityFilter] = useState<WarningSeverity | 'ALL'>('ALL');
useEffect(() => {}, [warnings]);
const filtered = severityFilter === 'ALL'
? warnings
: warnings.filter(w => w.severity === severityFilter);
return (
<aside className={styles.warningsSidebar}>
<WarningsHeader
severityFilter={severityFilter}
onChange={setSeverityFilter}
/>
<WarningsList warnings={filtered} />
</aside>
);
}
function WarningsHeader({
severityFilter,
onChange,
}: {
severityFilter: WarningSeverity | 'ALL';
onChange: (severity: WarningSeverity | 'ALL') => void;
}) {
const summary = warningSummary();
return (
<div className={styles.warningsHeader}>
<h3>Warnings</h3>
<div className={styles.severityTabs}>
{(['ALL', 'ERROR', 'WARNING', 'INFO'] as const).map(severity => (
<button
key={severity}
className={clsx(styles.severityTab, severityFilter === severity && styles.active)}
onClick={() => onChange(severity)}
>
{severity}
{severity !== 'ALL' && (
<span className={styles.count}>
{summary[severity.toLowerCase() as keyof typeof summary]}
</span>
)}
</button>
))}
</div>
</div>
);
}
function WarningsList(props: { warnings: EditorWarning[] }) {
const splitWarnings = {
global: props.warnings.filter(w => w.scope.id === globalWarning),
other: props.warnings.filter(w => w.scope.id !== globalWarning),
}
if (props.warnings.length === 0) {
return (
<div className={styles.warningsEmpty}>
No warnings!
</div>
)
}
return (
<div>
<div className={"warningGroup"}>
<div className={styles.warningGroupHeader}>global:</div>
<div className={styles.warningsList}>
{splitWarnings.global.map((warning) => (
<WarningListItem warning={warning} />
))}
{splitWarnings.global.length === 0 && "No global warnings!"}
</div>
</div>
<div className={"warningGroup"}>
<div className={styles.warningGroupHeader}>other:</div>
<div className={styles.warningsList}>
{splitWarnings.other.map((warning) => (
<WarningListItem warning={warning} />
))}
{splitWarnings.other.length === 0 && "No other warnings!"}
</div>
</div>
</div>
);
}
function WarningListItem(props: { warning: EditorWarning }) {
const jumpToNode = useJumpToNode();
return (
<div
className={clsx(styles.warningItem, styles[`warning-item--${props.warning.severity.toLowerCase()}`],)}
onClick={() => jumpToNode(props.warning.scope.id)}
>
<div className={styles.description}>
{props.warning.description}
</div>
<div className={styles.meta}>
{props.warning.scope.id}
{props.warning.scope.handleId && (
<span className={styles.handle}>@{props.warning.scope.handleId}</span>
)}
</div>
</div>
);
}
function useJumpToNode() {
const { getNode, setCenter } = useReactFlow();
const { addSelectedNodes } = useStoreApi().getState();
return (nodeId: string) => {
// user can't jump to global warning, so prevent further logic from running
if (nodeId === globalWarning) return;
const node = getNode(nodeId);
if (!node) return;
const { position, width = 0, height = 0} = node;
// move to node
setCenter(
position!.x + width / 2,
position!.y + height / 2,
{ zoom: 2, duration: 300 }
).then(() => {
// select the node
addSelectedNodes([nodeId]);
});
};
}

View File

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

View File

@@ -3,13 +3,14 @@ import {
Position,
type Node,
} from '@xyflow/react';
import { Toolbar } from '../components/NodeComponents';
import { Toolbar } from '../components/NodeComponents.tsx';
import styles from '../../VisProg.module.css';
import {MultiConnectionHandle} from "../components/RuleBasedHandle.tsx";
import {allowOnlyConnectionsFromHandle} from "../HandleRules.ts";
import useFlowStore from '../VisProgStores';
import { TextField } from '../../../../components/TextField';
import { MultilineTextField } from '../../../../components/MultilineTextField';
import useFlowStore from '../VisProgStores.tsx';
import { TextField } from '../../../../components/TextField.tsx';
import { MultilineTextField } from '../../../../components/MultilineTextField.tsx';
import {noMatchingLeftRightBelief} from "./BeliefGlobals.ts";
/**
* The default data structure for a BasicBelief node
@@ -31,7 +32,7 @@ export type BasicBeliefNodeData = {
};
// These are all the types a basic belief could be.
type BasicBeliefType = Keyword | Semantic | DetectedObject | Emotion
export type BasicBeliefType = Keyword | Semantic | DetectedObject | Emotion
type Keyword = { type: "keyword", id: string, value: string, label: "Keyword said:"};
type Semantic = { type: "semantic", id: string, value: string, description: string, label: "Detected with LLM:"};
type DetectedObject = { type: "object", id: string, value: string, label: "Object found:"};
@@ -112,8 +113,8 @@ export default function BasicBeliefNode(props: NodeProps<BasicBeliefNode>) {
updateNodeData(props.id, {...data, belief: {...data.belief, description: value}});
}
// Use this
const emotionOptions = ["Happy", "Angry", "Sad", "Cheerful"]
// These are the labels outputted by our emotion detection model
const emotionOptions = ["sad", "angry", "surprise", "fear", "happy", "disgust", "neutral"];
let placeholder = ""
@@ -189,7 +190,8 @@ export default function BasicBeliefNode(props: NodeProps<BasicBeliefNode>) {
</div>
)}
<MultiConnectionHandle type="source" position={Position.Right} id="source" rules={[
allowOnlyConnectionsFromHandle([{nodeType:"trigger",handleId:"TriggerBeliefs"}, {nodeType:"norm",handleId:"NormBeliefs"}]),
noMatchingLeftRightBelief,
allowOnlyConnectionsFromHandle([{nodeType:"trigger",handleId:"TriggerBeliefs"}, {nodeType:"norm",handleId:"NormBeliefs"}]),
]}/>
</div>
</>

View File

@@ -0,0 +1,63 @@
import {getOutgoers, type Node} from '@xyflow/react';
import {type HandleRule, type RuleResult, ruleResult} from "../HandleRuleLogic.ts";
import useFlowStore from "../VisProgStores.tsx";
import {BasicBeliefReduce} from "./BasicBeliefNode.tsx";
import {type InferredBeliefNodeData, InferredBeliefReduce} from "./InferredBeliefNode.tsx";
export function BeliefGlobalReduce(beliefNode: Node, nodes: Node[]) {
switch (beliefNode.type) {
case 'basic_belief':
return BasicBeliefReduce(beliefNode, nodes);
case 'inferred_belief':
return InferredBeliefReduce(beliefNode, nodes);
}
}
export const noMatchingLeftRightBelief : HandleRule = (connection, _)=> {
const { nodes } = useFlowStore.getState();
const thisNode = nodes.find(node => node.id === connection.target && node.type === 'inferred_belief');
if (!thisNode) return ruleResult.satisfied;
const iBelief = (thisNode.data as InferredBeliefNodeData).inferredBelief;
return (iBelief.left === connection.source || iBelief.right === connection.source)
? ruleResult.notSatisfied("Connecting one belief to both input handles of an inferred belief node is not allowed")
: ruleResult.satisfied;
}
/**
* makes it impossible to connect Inferred belief nodes
* if the connection would create a cyclical connection between inferred beliefs
*/
export const noBeliefCycles: HandleRule = (connection, _): RuleResult => {
const {nodes, edges} = useFlowStore.getState();
const defaultErrorMessage = "Cyclical connection exists between inferred beliefs";
/**
* recursively checks for cyclical connections between InferredBelief nodes
*
* to check for a cycle provide the source of an attempted connection as the targetNode for the cycle check,
* the currentNodeId should be initialised with the id of the targetNode of the attempted connection.
*
* @param {string} targetNodeId - the id of the node we are looking for as the endpoint of a cyclical connection
* @param {string} currentNodeId - the id of the node we are checking for outgoing connections to the provided target node
* @returns {RuleResult}
*/
function checkForCycle(targetNodeId: string, currentNodeId: string): RuleResult {
const outgoingBeliefs = getOutgoers({id: currentNodeId}, nodes, edges)
.filter(node => node.type === 'inferred_belief');
if (outgoingBeliefs.length === 0) return ruleResult.satisfied;
if (outgoingBeliefs.some(node => node.id === targetNodeId)) return ruleResult
.notSatisfied(defaultErrorMessage);
const next = outgoingBeliefs.map(node => checkForCycle(targetNodeId, node.id))
.find(result => !result.isSatisfied);
return next
? next
: ruleResult.satisfied;
}
return connection.source === connection.target
? ruleResult.notSatisfied(defaultErrorMessage)
: checkForCycle(connection.source, connection.target);
};

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,7 +9,7 @@ import { TextField } from '../../../../components/TextField';
import {MultiConnectionHandle, SingleConnectionHandle} from "../components/RuleBasedHandle.tsx";
import {allowOnlyConnectionsFromHandle, allowOnlyConnectionsFromType} from "../HandleRules.ts";
import useFlowStore from '../VisProgStores';
import { BasicBeliefReduce } from './BasicBeliefNode';
import {BeliefGlobalReduce} from "./BeliefGlobals.ts";
/**
* The default data dot a phase node
@@ -81,7 +81,7 @@ export default function NormNode(props: NodeProps<NormNode>) {
allowOnlyConnectionsFromHandle([{nodeType:"phase",handleId:"data"}])
]}/>
<SingleConnectionHandle type="target" position={Position.Bottom} id="NormBeliefs" rules={[
allowOnlyConnectionsFromType(["basic_belief"])
allowOnlyConnectionsFromType(["basic_belief", "inferred_belief"])
]}/>
</div>
</>;
@@ -105,11 +105,10 @@ export function NormReduce(node: Node, nodes: Node[]) {
};
if (data.condition) {
const reducer = BasicBeliefReduce; // TODO: also add inferred.
const conditionNode = nodes.find((node) => node.id === data.condition);
// In case something went wrong, and our condition doesn't actually exist;
if (conditionNode == undefined) return result;
result["condition"] = reducer(conditionNode, nodes)
result["condition"] = BeliefGlobalReduce(conditionNode, nodes)
}
return result
}
@@ -126,7 +125,7 @@ export const NormTooltip = `
export function NormConnectionTarget(_thisNode: Node, _sourceNodeId: string) {
const data = _thisNode.data as NormNodeData;
// If we got a belief connected, this is the condition for the norm.
if ((useFlowStore.getState().nodes.find((node) => node.id === _sourceNodeId && node.type === 'basic_belief' /* TODO: Add the option for an inferred belief */))) {
if ((useFlowStore.getState().nodes.find((node) => node.id === _sourceNodeId && ['basic_belief', 'inferred_belief'].includes(node.type!)))) {
data.condition = _sourceNodeId;
}
}

View File

@@ -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 {SingleConnectionHandle, MultiConnectionHandle} from "../components/RuleBasedHandle.tsx";
@@ -41,6 +43,28 @@ export default function PhaseNode(props: NodeProps<PhaseNode>) {
const updateLabel = (value: string) => updateNodeData(props.id, {...data, label: value});
const label_input_id = `phase_${props.id}_label_input`;
const {registerWarning, unregisterWarning} = useFlowStore.getState();
const connections = useNodeConnections({
id: props.id,
handleType: "target",
handleId: 'data'
})
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); }
else { unregisterWarning(props.id, `${noConnectionWarning.type}:data`); }
}, [connections.length, props.id, registerWarning, unregisterWarning]);
return (
<>
<Toolbar nodeId={props.id} allowDelete={true}/>

View File

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

View File

@@ -9,8 +9,8 @@ import {MultiConnectionHandle, SingleConnectionHandle} from "../components/RuleB
import {allowOnlyConnectionsFromHandle, allowOnlyConnectionsFromType} from "../HandleRules.ts";
import useFlowStore from '../VisProgStores';
import {PlanReduce, type Plan } from '../components/Plan';
import PlanEditorDialog from '../components/PlanEditor';
import { BasicBeliefReduce } from './BasicBeliefNode';
import PlanEditorDialog from '../components/PlanEditor';
import {BeliefGlobalReduce} from "./BeliefGlobals.ts";
import type { GoalNode } from './GoalNode.tsx';
import { defaultPlan } from '../components/Plan.default.ts';
import { deleteGoalInPlanByID, insertGoalInPlan } from '../components/PlanEditingFunctions.tsx';
@@ -50,9 +50,9 @@ export default function TriggerNode(props: NodeProps<TriggerNode>) {
const setName= (value: string) => {
updateNodeData(props.id, {...data, name: value})
}
return <>
<Toolbar nodeId={props.id} allowDelete={true}/>
<div className={`${styles.defaultNode} ${styles.nodeTrigger} flex-col gap-sm`}>
<TextField
@@ -70,9 +70,9 @@ export default function TriggerNode(props: NodeProps<TriggerNode>) {
type="target"
position={Position.Bottom}
id="TriggerBeliefs"
style={{ left: '40%' }}
style={{ left: '40%' }}
rules={[
allowOnlyConnectionsFromType(['basic_belief']),
allowOnlyConnectionsFromType(["basic_belief","inferred_belief"]),
]}
/>
@@ -102,13 +102,13 @@ export default function TriggerNode(props: NodeProps<TriggerNode>) {
/**
* Reduces each Trigger, including its children down into its core data.
* @param node - The Trigger node to reduce.
* @param _nodes - The list of all nodes in the current flow graph.
* @param nodes - The list of all nodes in the current flow graph.
* @returns A simplified object containing the node label and its list of triggers.
*/
export function TriggerReduce(node: Node, nodes: Node[]) {
const data = node.data as TriggerNodeData;
const conditionNode = data.condition ? nodes.find((n)=>n.id===data.condition) : undefined
const conditionData = conditionNode ? BasicBeliefReduce(conditionNode, nodes) : ""
const conditionData = conditionNode ? BeliefGlobalReduce(conditionNode, nodes) : ""
return {
id: node.id,
name: node.data.name,
@@ -136,7 +136,7 @@ export function TriggerConnectionTarget(_thisNode: Node, _sourceNodeId: string)
const otherNode = nodes.find((x) => x.id === _sourceNodeId)
if (!otherNode) return;
if (otherNode.type === 'basic_belief' /* TODO: Add the option for an inferred belief */) {
if (['basic_belief', 'inferred_belief'].includes(otherNode.type!)) {
data.condition = _sourceNodeId;
}
@@ -172,7 +172,7 @@ export function TriggerDisconnectionTarget(_thisNode: Node, _sourceNodeId: strin
const data = _thisNode.data as TriggerNodeData;
// remove if the target of disconnection was our condition
if (_sourceNodeId == data.condition) data.condition = undefined
data.plan = deleteGoalInPlanByID(structuredClone(data.plan) as Plan, _sourceNodeId)
}

3
src/utils/capitalize.ts Normal file
View File

@@ -0,0 +1,3 @@
export default function (s: string) {
return s.charAt(0).toUpperCase() + s.slice(1);
}

View File

@@ -0,0 +1,7 @@
export default async function <T>(promise: Promise<T>, minDelayMs: number): Promise<T> {
const [result] = await Promise.all([
promise,
new Promise(resolve => setTimeout(resolve, minDelayMs))
]);
return result;
}

View File

@@ -4,7 +4,7 @@ export type PriorityFilterPredicate<T> = {
}
/**
* 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.
*/

View File

@@ -3,6 +3,8 @@ import {create} from "zustand";
// the type of a reduced program
export type ReducedProgram = { phases: Record<string, unknown>[] };
export type GoalWithDepth = Record<string, unknown> & { level: number };
/**
* the type definition of the programStore
*/
@@ -15,8 +17,10 @@ export type ProgramState = {
// Utility functions:
// to avoid having to manually go through the entire state for every instance where data is required
getPhaseIds: () => string[];
getPhaseNames: () => string[];
getNormsInPhase: (currentPhaseId: string) => Record<string, unknown>[];
getGoalsInPhase: (currentPhaseId: string) => Record<string, unknown>[];
getGoalsWithDepth: (currentPhaseId: string) => GoalWithDepth[];
getTriggersInPhase: (currentPhaseId: string) => Record<string, unknown>[];
// if more specific utility functions are needed they can be added here:
}
@@ -43,6 +47,10 @@ const useProgramStore = create<ProgramState>((set, get) => ({
* gets the ids of all phases in the program
*/
getPhaseIds: () => get().currentProgram.phases.map(entry => entry["id"] as string),
/**
* gets the names of all phases in the program
*/
getPhaseNames: () => get().currentProgram.phases.map((entry) => (entry["name"] as string)),
/**
* gets the norms for the provided phase
*/
@@ -65,6 +73,51 @@ const useProgramStore = create<ProgramState>((set, get) => ({
}
throw new Error(`phase with id:"${currentPhaseId}" not found`)
},
getGoalsWithDepth: (currentPhaseId: string) => {
const program = get().currentProgram;
const phase = program.phases.find(val => val["id"] === currentPhaseId);
if (!phase) {
throw new Error(`phase with id:"${currentPhaseId}" not found`);
}
const rootGoals = phase["goals"] as Record<string, unknown>[];
const flatList: GoalWithDepth[] = [];
// Helper: Define this ONCE, outside the loop
const isGoal = (item: Record<string, unknown>) => {
return item["plan"] !== undefined && item["plan"] !== null;
};
// Recursive helper function
const traverse = (goals: Record<string, unknown>[], depth: number) => {
goals.forEach((goal) => {
// 1. Add the current goal to the list
flatList.push({ ...goal, level: depth });
// 2. Check for children
const plan = goal["plan"] as Record<string, unknown> | undefined;
if (plan && Array.isArray(plan["steps"])) {
const steps = plan["steps"] as Record<string, unknown>[];
// 3. FILTER: Only recurse on steps that are actually goals
// If we just passed 'steps', we might accidentally add Actions/Speeches to the goal list
const childGoals = steps.filter(isGoal);
if (childGoals.length > 0) {
traverse(childGoals, depth + 1);
}
}
});
};
// Start traversal
traverse(rootGoals, 0);
return flatList;
},
/**
* gets the triggers for the provided phase
*/

View File

@@ -11,8 +11,6 @@ const loggingStoreRef: { current: null | { setState: (state: Partial<LoggingSett
type LoggingSettingsState = {
showRelativeTime: boolean;
setShowRelativeTime: (show: boolean) => 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(<Logging/>);
@@ -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(<Logging/>);
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 = {

View File

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

View File

@@ -0,0 +1,229 @@
import { renderHook, act, cleanup } from '@testing-library/react';
import {
sendAPICall,
nextPhase,
resetPhase,
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('resetPhase sends correct params', async () => {
await resetPhase();
expect(globalThis.fetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ body: JSON.stringify({ type: 'reset_phase', context: '' }) })
);
});
test('pauseExperiment sends correct params', async () => {
await pauseExperiment();
expect(globalThis.fetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ body: JSON.stringify({ type: 'pause', context: 'true' }) })
);
});
test('playExperiment sends correct params', async () => {
await playExperiment();
expect(globalThis.fetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ body: JSON.stringify({ type: 'pause', context: 'false' }) })
);
});
});
describe('useExperimentLogger', () => {
test('connects to SSE and receives messages', () => {
const onUpdate = jest.fn();
// Hook must be rendered to start the effect
renderHook(() => useExperimentLogger(onUpdate));
// Retrieve the mocked instance created by the hook
const eventSource = mockInstances[0];
expect(eventSource.url).toContain('/experiment_stream');
// Simulate incoming message
act(() => {
eventSource.sendMessage(JSON.stringify({ type: 'phase_update', id: '1' }));
});
expect(onUpdate).toHaveBeenCalledWith({ type: 'phase_update', id: '1' });
});
test('handles JSON parse errors in stream', () => {
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
renderHook(() => useExperimentLogger());
const eventSource = mockInstances[0];
act(() => {
eventSource.sendMessage('invalid-json');
});
expect(consoleSpy).toHaveBeenCalledWith('Stream parse error:', expect.any(Error));
});
test('handles SSE connection error', () => {
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
renderHook(() => useExperimentLogger());
const eventSource = mockInstances[0];
act(() => {
eventSource.triggerError('Connection lost');
});
expect(consoleSpy).toHaveBeenCalledWith('SSE Connection Error:', 'Connection lost');
expect(eventSource.closed).toBe(true);
});
test('closes EventSource on unmount', () => {
const { unmount } = renderHook(() => useExperimentLogger());
const eventSource = mockInstances[0];
const closeSpy = jest.spyOn(eventSource, 'close');
unmount();
expect(closeSpy).toHaveBeenCalled();
expect(eventSource.closed).toBe(true);
});
});
describe('useStatusLogger', () => {
test('connects to SSE and receives messages', () => {
const onUpdate = jest.fn();
renderHook(() => useStatusLogger(onUpdate));
const eventSource = mockInstances[0];
expect(eventSource.url).toContain('/status_stream');
act(() => {
eventSource.sendMessage(JSON.stringify({ some: 'data' }));
});
expect(onUpdate).toHaveBeenCalledWith({ some: 'data' });
});
test('handles JSON parse errors', () => {
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
renderHook(() => useStatusLogger());
const eventSource = mockInstances[0];
act(() => {
eventSource.sendMessage('bad-data');
});
expect(consoleSpy).toHaveBeenCalledWith('Status stream error:', expect.any(Error));
});
});
});

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,7 @@ import { describe, it, beforeEach } from '@jest/globals';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders } from '../.././/./../../test-utils/test-utils';
import BasicBeliefNode, { type BasicBeliefNodeData } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode';
import BasicBeliefNode, { type BasicBeliefNodeData } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx';
import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores';
import type { Node } from '@xyflow/react';
import '@testing-library/jest-dom';

View File

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

View File

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

View File

@@ -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');
});
});

View File

@@ -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<string>(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 <T>(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');
});
});

View File

@@ -113,4 +113,31 @@ describe('useProgramStore', () => {
// store should NOT change
expect(storedProgram.phases[0]['norms']).toHaveLength(1);
});
});
});
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']);
});