Compare commits

68 Commits

Author SHA1 Message Date
JobvAlewijk
a4f7b48031 Merge branch 'feat/monitoringpage-pim' of ssh://git.science.uu.nl/ics/sp/2025/n25b/pepperplus-ui into feat/monitoringpage-pim 2026-01-29 16:24:19 +01:00
JobvAlewijk
b18cd5bfa5 chore: face 2026-01-29 16:24:03 +01:00
Pim Hutting
09e6287f9d chore: edited a test to pass 2026-01-26 14:35:46 +01:00
Pim Hutting
8b40001038 Merge branch 'dev' into feat/monitoringpage-pim 2026-01-26 14:22:13 +01:00
Pim Hutting
9a555165e6 Merge branch 'dev' into feat/monitoringpage-pim 2026-01-22 10:19:46 +01:00
Pim Hutting
f73bbb9d02 chore: added tests and removed restart phase
this version also has recursive goals functional
2026-01-22 10:15:20 +01:00
Pim Hutting
883f0a95a6 chore: only check if play is undefined 2026-01-20 13:55:34 +01:00
Pim Hutting
a8f9965391 chore: added recursive goals to monitor page 2026-01-20 12:31:34 +01:00
Pim Hutting
2ca0c9c4c0 chore: added Storms change 2026-01-19 18:17:47 +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
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
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
21 changed files with 2495 additions and 54 deletions

View File

@@ -8,6 +8,7 @@ import VisProg from "./pages/VisProgPage/VisProg.tsx";
import {useState} from "react";
import Logging from "./components/Logging/Logging.tsx";
function App(){
const [showLogs, setShowLogs] = useState(false);

View File

@@ -8,6 +8,9 @@
background-color: #242424;
--accent-color: #008080;
--panel-shadow:
0 1px 2px white,
0 8px 24px rgba(190, 186, 186, 0.253);
font-synthesis: none;
text-rendering: optimizeLegibility;
@@ -15,6 +18,14 @@
-moz-osx-font-smoothing: grayscale;
}
@media (prefers-color-scheme: dark) {
:root {
--panel-shadow:
0 1px 2px rgba(221, 221, 221, 0.178),
0 8px 24px rgba(27, 27, 27, 0.507);
}
}
html, body, #root {
margin: 0;
padding: 0;

View File

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

View File

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

View File

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

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,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

@@ -183,6 +183,18 @@
left: 60% !important;
}
.planNoIterate {
opacity: 0.5;
font-style: italic;
text-decoration: line-through;
}
.backButton {
background: var(--bg-surface);
box-shadow: var(--panel-shadow);
margin-top: 0.5rem;
margin-left: 0.5rem;
}
.node-toolbar-tooltip {
background-color: darkgray;
color: white;

View File

@@ -9,15 +9,15 @@ import {
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 {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 |--
@@ -144,42 +144,6 @@ 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.");
// 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);
}
/**
* 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)
});
}
/**
* houses the entire page, so also UI elements
@@ -187,6 +151,27 @@ 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>
);
}
return (
<>
<VisualProgrammingUI/>

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

@@ -32,11 +32,12 @@ export type BasicBeliefNodeData = {
};
// These are all the types a basic belief could be.
export type BasicBeliefType = Keyword | Semantic | DetectedObject | Emotion
export type BasicBeliefType = Keyword | Semantic | DetectedObject | Emotion | Face
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:"};
type Emotion = { type: "emotion", id: string, value: string, label: "Emotion recognised:"};
type Face = { type: "face", id: string, value: string, label: "Face detected"};
export type BasicBeliefNode = Node<BasicBeliefNodeData>
@@ -156,6 +157,7 @@ export default function BasicBeliefNode(props: NodeProps<BasicBeliefNode>) {
<option value="semantic">Detected with LLM:</option>
<option value="object">Object found:</option>
<option value="emotion">Emotion recognised:</option>
<option value="face">Face detected</option>
</select>
{wrapping}
{data.belief.type === "emotion" && (
@@ -191,7 +193,7 @@ export default function BasicBeliefNode(props: NodeProps<BasicBeliefNode>) {
)}
<MultiConnectionHandle type="source" position={Position.Right} id="source" rules={[
noMatchingLeftRightBelief,
allowOnlyConnectionsFromHandle([{nodeType:"trigger",handleId:"TriggerBeliefs"}, {nodeType:"norm",handleId:"NormBeliefs"}]),
allowOnlyConnectionsFromHandle([{nodeType:"trigger",handleId:"TriggerBeliefs"}, {nodeType:"norm",handleId:"NormBeliefs"},{nodeType:"InferredBelief",handleId:"inferred_belief"}]),
]}/>
</div>
</>
@@ -224,6 +226,10 @@ export function BasicBeliefReduce(node: Node, _nodes: Node[]) {
result["name"] = data.belief.value;
result["description"] = data.belief.description;
break;
case "face":
result["face_present"] = true;
break;
default:
break;
}

View File

@@ -72,7 +72,7 @@ export default function TriggerNode(props: NodeProps<TriggerNode>) {
id="TriggerBeliefs"
style={{ left: '40%' }}
rules={[
allowOnlyConnectionsFromType(["basic_belief","inferred_belief"]),
allowOnlyConnectionsFromType(['basic_belief']),
]}
/>
@@ -136,7 +136,7 @@ export function TriggerConnectionTarget(_thisNode: Node, _sourceNodeId: string)
const otherNode = nodes.find((x) => x.id === _sourceNodeId)
if (!otherNode) return;
if (['basic_belief', 'inferred_belief'].includes(otherNode.type!)) {
if (otherNode.type === 'basic_belief' /* TODO: Add the option for an inferred belief */) {
data.condition = _sourceNodeId;
}

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,50 @@ const useProgramStore = create<ProgramState>((set, get) => ({
}
throw new Error(`phase with id:"${currentPhaseId}" not found`)
},
getGoalsWithDepth: (currentPhaseId: string) => {
const program = get().currentProgram;
const phase = program.phases.find(val => val["id"] === currentPhaseId);
if (!phase) {
throw new Error(`phase with id:"${currentPhaseId}" not found`);
}
const rootGoals = phase["goals"] as Record<string, unknown>[];
const flatList: GoalWithDepth[] = [];
const isGoal = (item: Record<string, unknown>) => {
return item["plan"] !== undefined;
};
// Recursive helper function
const traverse = (goals: Record<string, unknown>[], depth: number) => {
goals.forEach((goal) => {
// 1. Add the current goal to the list
flatList.push({ ...goal, level: depth });
// 2. Check for children
const plan = goal["plan"] as Record<string, unknown> | undefined;
if (plan && Array.isArray(plan["steps"])) {
const steps = plan["steps"] as Record<string, unknown>[];
// 3. FILTER: Only recurse on steps that are actually goals
// If we just passed 'steps', we might accidentally add Actions/Speeches to the goal list
const childGoals = steps.filter(isGoal);
if (childGoals.length > 0) {
traverse(childGoals, depth + 1);
}
}
});
};
// Start traversal
traverse(rootGoals, 0);
return flatList;
},
/**
* gets the triggers for the provided phase
*/

View File

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

View File

@@ -0,0 +1,220 @@
import { renderHook, act, cleanup } from '@testing-library/react';
import {
sendAPICall,
nextPhase,
pauseExperiment,
playExperiment,
useExperimentLogger,
useStatusLogger
} from '../../../src/pages/MonitoringPage/MonitoringPageAPI';
// --- MOCK EVENT SOURCE SETUP ---
// This mocks the browser's EventSource so we can manually 'push' messages to our hooks
const mockInstances: MockEventSource[] = [];
class MockEventSource {
url: string;
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: ((event: Event) => void) | null = null; // Added onerror support
closed = false;
constructor(url: string) {
this.url = url;
mockInstances.push(this);
}
sendMessage(data: string) {
if (this.onmessage) {
this.onmessage({ data } as MessageEvent);
}
}
triggerError(err: any) {
if (this.onerror) {
this.onerror(err);
}
}
close() {
this.closed = true;
}
}
// Mock global EventSource
beforeAll(() => {
(globalThis as any).EventSource = jest.fn((url: string) => new MockEventSource(url));
});
// Mock global fetch
beforeEach(() => {
globalThis.fetch = jest.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ reply: 'ok' }),
})
) as jest.Mock;
});
// Cleanup after every test
afterEach(() => {
cleanup();
jest.restoreAllMocks();
mockInstances.length = 0;
});
describe('MonitoringPageAPI', () => {
describe('sendAPICall', () => {
test('sends correct POST request', async () => {
await sendAPICall('test_type', 'test_ctx');
expect(globalThis.fetch).toHaveBeenCalledWith(
'http://localhost:8000/button_pressed',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'test_type', context: 'test_ctx' }),
})
);
});
test('appends endpoint if provided', async () => {
await sendAPICall('t', 'c', '/extra');
expect(globalThis.fetch).toHaveBeenCalledWith(
expect.stringContaining('/button_pressed/extra'),
expect.any(Object)
);
});
test('logs error on fetch network failure', async () => {
(globalThis.fetch as jest.Mock).mockRejectedValue('Network error');
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
await sendAPICall('t', 'c');
expect(consoleSpy).toHaveBeenCalledWith('Failed to send api call:', 'Network error');
});
test('throws error if response is not ok', async () => {
(globalThis.fetch as jest.Mock).mockResolvedValue({ ok: false });
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
await sendAPICall('t', 'c');
expect(consoleSpy).toHaveBeenCalledWith('Failed to send api call:', expect.any(Error));
});
});
describe('Helper Functions', () => {
test('nextPhase sends correct params', async () => {
await nextPhase();
expect(globalThis.fetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ body: JSON.stringify({ type: 'next_phase', context: '' }) })
);
});
test('pauseExperiment sends correct params', async () => {
await pauseExperiment();
expect(globalThis.fetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ body: JSON.stringify({ type: 'pause', context: 'true' }) })
);
});
test('playExperiment sends correct params', async () => {
await playExperiment();
expect(globalThis.fetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ body: JSON.stringify({ type: 'pause', context: 'false' }) })
);
});
});
describe('useExperimentLogger', () => {
test('connects to SSE and receives messages', () => {
const onUpdate = jest.fn();
// Hook must be rendered to start the effect
renderHook(() => useExperimentLogger(onUpdate));
// Retrieve the mocked instance created by the hook
const eventSource = mockInstances[0];
expect(eventSource.url).toContain('/experiment_stream');
// Simulate incoming message
act(() => {
eventSource.sendMessage(JSON.stringify({ type: 'phase_update', id: '1' }));
});
expect(onUpdate).toHaveBeenCalledWith({ type: 'phase_update', id: '1' });
});
test('handles JSON parse errors in stream', () => {
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
renderHook(() => useExperimentLogger());
const eventSource = mockInstances[0];
act(() => {
eventSource.sendMessage('invalid-json');
});
expect(consoleSpy).toHaveBeenCalledWith('Stream parse error:', expect.any(Error));
});
test('handles SSE connection error', () => {
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
renderHook(() => useExperimentLogger());
const eventSource = mockInstances[0];
act(() => {
eventSource.triggerError('Connection lost');
});
expect(consoleSpy).toHaveBeenCalledWith('SSE Connection Error:', 'Connection lost');
expect(eventSource.closed).toBe(true);
});
test('closes EventSource on unmount', () => {
const { unmount } = renderHook(() => useExperimentLogger());
const eventSource = mockInstances[0];
const closeSpy = jest.spyOn(eventSource, 'close');
unmount();
expect(closeSpy).toHaveBeenCalled();
expect(eventSource.closed).toBe(true);
});
});
describe('useStatusLogger', () => {
test('connects to SSE and receives messages', () => {
const onUpdate = jest.fn();
renderHook(() => useStatusLogger(onUpdate));
const eventSource = mockInstances[0];
expect(eventSource.url).toContain('/status_stream');
act(() => {
eventSource.sendMessage(JSON.stringify({ some: 'data' }));
});
expect(onUpdate).toHaveBeenCalledWith({ some: 'data' });
});
test('handles JSON parse errors', () => {
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
renderHook(() => useStatusLogger());
const eventSource = mockInstances[0];
act(() => {
eventSource.sendMessage('bad-data');
});
expect(consoleSpy).toHaveBeenCalledWith('Status stream error:', expect.any(Error));
});
});
});

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

@@ -105,6 +105,8 @@ describe("SaveLoadPanel - combined tests", () => {
});
test("onLoad with invalid JSON does not update store", async () => {
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
const file = new File(["not json"], "bad.json", { type: "application/json" });
file.text = jest.fn(() => Promise.resolve(`{"bad json`));
@@ -112,20 +114,19 @@ describe("SaveLoadPanel - combined tests", () => {
render(<SaveLoadPanel />);
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
expect(input).toBeTruthy();
// Give some input
act(() => {
fireEvent.change(input, { target: { files: [file] } });
});
await waitFor(() => {
expect(window.alert).toHaveBeenCalledTimes(1);
const nodesAfter = useFlowStore.getState().nodes;
expect(nodesAfter).toHaveLength(0);
expect(input.value).toBe("");
});
// Clean up the spy
consoleSpy.mockRestore();
});
test("onLoad resolves to null when no file is chosen (user cancels) and does not update store", async () => {

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

View File

@@ -114,3 +114,113 @@ describe('useProgramStore', () => {
expect(storedProgram.phases[0]['norms']).toHaveLength(1);
});
});
describe('getGoalsWithDepth', () => {
const complexProgram: ReducedProgram = {
phases: [
{
id: 'phase-nested',
goals: [
// Level 0: Root Goal 1
{
id: 'root-1',
name: 'Root Goal 1',
plan: {
steps: [
// This is an ACTION (no plan), should be ignored
{ id: 'action-1', type: 'speech' },
// Level 1: Child Goal
{
id: 'child-1',
name: 'Child Goal',
plan: {
steps: [
// Level 2: Grandchild Goal
{
id: 'grandchild-1',
name: 'Grandchild',
plan: { steps: [] } // Empty plan is still a plan
}
]
}
}
]
}
},
// Level 0: Root Goal 2 (Sibling)
{
id: 'root-2',
name: 'Root Goal 2',
plan: { steps: [] }
}
]
}
]
};
it('should flatten nested goals and assign correct depth levels', () => {
useProgramStore.getState().setProgramState(complexProgram);
const goals = useProgramStore.getState().getGoalsWithDepth('phase-nested');
// logic: Root 1 -> Child 1 -> Grandchild 1 -> Root 2
expect(goals).toHaveLength(4);
// Check Root 1
expect(goals[0]).toEqual(expect.objectContaining({ id: 'root-1', level: 0 }));
// Check Child 1
expect(goals[1]).toEqual(expect.objectContaining({ id: 'child-1', level: 1 }));
// Check Grandchild 1
expect(goals[2]).toEqual(expect.objectContaining({ id: 'grandchild-1', level: 2 }));
// Check Root 2
expect(goals[3]).toEqual(expect.objectContaining({ id: 'root-2', level: 0 }));
});
it('should ignore steps that are not goals (missing "plan" property)', () => {
useProgramStore.getState().setProgramState(complexProgram);
const goals = useProgramStore.getState().getGoalsWithDepth('phase-nested');
// The 'action-1' object should NOT be in the list
const action = goals.find(g => g.id === 'action-1');
expect(action).toBeUndefined();
});
it('throws if phase does not exist', () => {
useProgramStore.getState().setProgramState(complexProgram);
expect(() =>
useProgramStore.getState().getGoalsWithDepth('missing-phase')
).toThrow('phase with id:"missing-phase" not found');
});
});
it('should return the names of all phases in the program', () => {
// Define a program specifically with names for this test
const programWithNames: ReducedProgram = {
phases: [
{
id: 'phase-1',
name: 'Introduction Phase', // Assuming the property is 'name'
norms: [],
goals: [],
triggers: [],
},
{
id: 'phase-2',
name: 'Execution Phase',
norms: [],
goals: [],
triggers: [],
},
],
};
useProgramStore.getState().setProgramState(programWithNames);
const phaseNames = useProgramStore.getState().getPhaseNames();
expect(phaseNames).toEqual(['Introduction Phase', 'Execution Phase']);
});