Merge branch 'feat/monitoringpage-pim' into demo
This commit is contained in:
@@ -8,6 +8,7 @@ import VisProg from "./pages/VisProgPage/VisProg.tsx";
|
||||
import {useState} from "react";
|
||||
import Logging from "./components/Logging/Logging.tsx";
|
||||
|
||||
|
||||
function App(){
|
||||
const [showLogs, setShowLogs] = useState(false);
|
||||
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
background-color: #242424;
|
||||
|
||||
--accent-color: #008080;
|
||||
--panel-shadow:
|
||||
0 1px 2px white,
|
||||
0 8px 24px rgba(190, 186, 186, 0.253);
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
@@ -15,6 +18,14 @@
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--panel-shadow:
|
||||
0 1px 2px rgba(221, 221, 221, 0.178),
|
||||
0 8px 24px rgba(27, 27, 27, 0.507);
|
||||
}
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
281
src/pages/MonitoringPage/MonitoringPage.module.css
Normal file
281
src/pages/MonitoringPage/MonitoringPage.module.css
Normal file
@@ -0,0 +1,281 @@
|
||||
.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;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
}
|
||||
441
src/pages/MonitoringPage/MonitoringPage.tsx
Normal file
441
src/pages/MonitoringPage/MonitoringPage.tsx
Normal file
@@ -0,0 +1,441 @@
|
||||
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';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// 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 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;
|
||||
131
src/pages/MonitoringPage/MonitoringPageAPI.ts
Normal file
131
src/pages/MonitoringPage/MonitoringPageAPI.ts
Normal 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();
|
||||
}, []);
|
||||
}
|
||||
232
src/pages/MonitoringPage/MonitoringPageComponents.tsx
Normal file
232
src/pages/MonitoringPage/MonitoringPageComponents.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import styles from './MonitoringPage.module.css';
|
||||
import { sendAPICall } from './MonitoringPageAPI';
|
||||
|
||||
// --- GESTURE COMPONENT ---
|
||||
export const GestureControls: React.FC = () => {
|
||||
const [selectedGesture, setSelectedGesture] = useState("animations/Stand/BodyTalk/Speaking/BodyTalk_1");
|
||||
|
||||
const gestures = [
|
||||
{ label: "Wave", value: "animations/Stand/Gestures/Hey_1" },
|
||||
{ label: "Think", value: "animations/Stand/Emotions/Neutral/Puzzled_1" },
|
||||
{ label: "Explain", value: "animations/Stand/Gestures/Explain_4" },
|
||||
{ label: "You", value: "animations/Stand/Gestures/You_1" },
|
||||
{ label: "Happy", value: "animations/Stand/Emotions/Positive/Happy_1" },
|
||||
{ label: "Laugh", value: "animations/Stand/Emotions/Positive/Laugh_2" },
|
||||
{ label: "Lonely", value: "animations/Stand/Emotions/Neutral/Lonely_1" },
|
||||
{ label: "Suprise", value: "animations/Stand/Emotions/Negative/Surprise_1" },
|
||||
{ label: "Hurt", value: "animations/Stand/Emotions/Negative/Hurt_2" },
|
||||
{ label: "Angry", value: "animations/Stand/Emotions/Negative/Angry_4" },
|
||||
];
|
||||
return (
|
||||
<div className={styles.gestures}>
|
||||
<h4>Gestures</h4>
|
||||
<div className={styles.gestureInputGroup}>
|
||||
<select
|
||||
value={selectedGesture}
|
||||
onChange={(e) => setSelectedGesture(e.target.value)}
|
||||
>
|
||||
{gestures.map(g => <option key={g.value} value={g.value}>{g.label}</option>)}
|
||||
</select>
|
||||
<button onClick={() => sendAPICall("gesture", selectedGesture)}>
|
||||
Actuate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- PRESET SPEECH COMPONENT ---
|
||||
export const SpeechPresets: React.FC = () => {
|
||||
const phrases = [
|
||||
{ label: "Hello, I'm Pepper", text: "Hello, I'm Pepper" },
|
||||
{ label: "Repeat please", text: "Could you repeat that please" },
|
||||
{ label: "About yourself", text: "Tell me something about yourself" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={styles.speech}>
|
||||
<h4>Speech Presets</h4>
|
||||
<ul>
|
||||
{phrases.map((phrase, i) => (
|
||||
<li key={i}>
|
||||
<button
|
||||
className={styles.speechBtn}
|
||||
onClick={() => sendAPICall("speech", phrase.text)}
|
||||
>
|
||||
"{phrase.label}"
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- DIRECT SPEECH (INPUT) COMPONENT ---
|
||||
export const DirectSpeechInput: React.FC = () => {
|
||||
const [text, setText] = useState("");
|
||||
|
||||
const handleSend = () => {
|
||||
if (!text.trim()) return;
|
||||
sendAPICall("speech", text);
|
||||
setText(""); // Clear after sending
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.directSpeech}>
|
||||
<h4>Direct Pepper Speech</h4>
|
||||
<div className={styles.speechInput}>
|
||||
<input
|
||||
type="text"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder="Type message..."
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
|
||||
/>
|
||||
<button onClick={handleSend}>Send</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- interface for goals/triggers/norms/conditional norms ---
|
||||
export type StatusItem = {
|
||||
id?: string | number;
|
||||
achieved?: boolean;
|
||||
description?: string;
|
||||
label?: string;
|
||||
norm?: string;
|
||||
name?: string;
|
||||
level?: number;
|
||||
};
|
||||
|
||||
interface StatusListProps {
|
||||
title: string;
|
||||
items: StatusItem[];
|
||||
type: 'goal' | 'trigger' | 'norm'| 'cond_norm';
|
||||
activeIds: Record<string, boolean>;
|
||||
setActiveIds?: React.Dispatch<React.SetStateAction<Record<string, boolean>>>;
|
||||
currentGoalIndex?: number;
|
||||
}
|
||||
|
||||
// --- STATUS LIST COMPONENT ---
|
||||
export const StatusList: React.FC<StatusListProps> = ({
|
||||
title,
|
||||
items,
|
||||
type,
|
||||
activeIds,
|
||||
setActiveIds,
|
||||
currentGoalIndex // Destructure this prop
|
||||
}) => {
|
||||
return (
|
||||
<section className={styles.phaseBox}>
|
||||
<h3>{title}</h3>
|
||||
<ul>
|
||||
{items.map((item, idx) => {
|
||||
if (item.id === undefined) return null;
|
||||
const isActive = !!activeIds[item.id];
|
||||
const showIndicator = type !== 'norm';
|
||||
const isCurrentGoal = type === 'goal' && idx === currentGoalIndex;
|
||||
const canOverride = (showIndicator && !isActive) || (type === 'cond_norm' && isActive);
|
||||
|
||||
const indentation = (item.level || 0) * 20;
|
||||
|
||||
const handleOverrideClick = () => {
|
||||
if (!canOverride) return;
|
||||
if (type === 'cond_norm' && isActive){
|
||||
{/* Unachieve conditional norm */}
|
||||
sendAPICall("override_unachieve", String(item.id));
|
||||
}
|
||||
else {
|
||||
if(type === 'goal')
|
||||
if(setActiveIds)
|
||||
{setActiveIds(prev => ({ ...prev, [String(item.id)]: true }));}
|
||||
|
||||
sendAPICall("override", String(item.id));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<li key={item.id ?? idx}
|
||||
className={styles.statusItem}
|
||||
style={{ paddingLeft: `${indentation}px` }}
|
||||
>
|
||||
{showIndicator && (
|
||||
<span
|
||||
className={`${styles.statusIndicator} ${isActive ? styles.active : styles.inactive} ${canOverride ? styles.clickable : ''}`}
|
||||
onClick={handleOverrideClick}
|
||||
>
|
||||
{isActive ? "✔️" : "❌"}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={styles.itemDescription}
|
||||
style={{
|
||||
// Visual Feedback
|
||||
textDecoration: isCurrentGoal ? 'underline' : 'none',
|
||||
fontWeight: isCurrentGoal ? 'bold' : 'normal',
|
||||
color: isCurrentGoal ? '#007bff' : 'inherit',
|
||||
backgroundColor: isCurrentGoal ? '#e7f3ff' : 'transparent', // Added subtle highlight
|
||||
padding: isCurrentGoal ? '2px 4px' : '0',
|
||||
borderRadius: '4px'
|
||||
}}
|
||||
>
|
||||
{item.name || item.norm}
|
||||
{isCurrentGoal && " (Current)"}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
// --- Robot Connected ---
|
||||
export const RobotConnected = () => {
|
||||
|
||||
/**
|
||||
* The current connection state:
|
||||
* - `true`: Robot is connected.
|
||||
* - `false`: Robot is not connected.
|
||||
* - `null`: Connection status is unknown (initial check in progress).
|
||||
*/
|
||||
const [connected, setConnected] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Open a Server-Sent Events (SSE) connection to receive live ping updates.
|
||||
// We're expecting a stream of data like that looks like this: `data = False` or `data = True`
|
||||
const eventSource = new EventSource("http://localhost:8000/robot/ping_stream");
|
||||
eventSource.onmessage = (event) => {
|
||||
|
||||
// Expecting messages in JSON format: `true` or `false`
|
||||
//commented out this log as it clutters console logs, but might be useful to debug
|
||||
//console.log("received message:", event.data);
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
try {
|
||||
setConnected(data)
|
||||
}
|
||||
catch {
|
||||
console.log("couldnt extract connected from incoming ping data")
|
||||
}
|
||||
|
||||
} catch {
|
||||
console.log("Ping message not in correct format:", event.data);
|
||||
}
|
||||
};
|
||||
|
||||
// Clean up the SSE connection when the component unmounts.
|
||||
return () => eventSource.close();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3>Connection:</h3>
|
||||
<p className={connected ? styles.connected : styles.disconnected }>{connected ? "● Robot is connected" : "● Robot is disconnected"}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
167
src/pages/SimpleProgram/SimpleProgram.module.css
Normal file
167
src/pages/SimpleProgram/SimpleProgram.module.css
Normal 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;
|
||||
}
|
||||
192
src/pages/SimpleProgram/SimpleProgram.tsx
Normal file
192
src/pages/SimpleProgram/SimpleProgram.tsx
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -9,7 +9,6 @@ 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 EditorWarning, globalWarning} from "./visualProgrammingUI/components/EditorWarnings.tsx";
|
||||
@@ -18,8 +17,10 @@ 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 |--
|
||||
|
||||
@@ -232,6 +233,27 @@ const checkPhaseChain = (): boolean => {
|
||||
* @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();
|
||||
|
||||
|
||||
43
src/pages/VisProgPage/VisProgLogic.ts
Normal file
43
src/pages/VisProgPage/VisProgLogic.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import useProgramStore from "../../utils/programStore";
|
||||
import orderPhaseNodeArray from "../../utils/orderPhaseNodes";
|
||||
import useFlowStore from './visualProgrammingUI/VisProgStores';
|
||||
import { NodeReduces } from './visualProgrammingUI/NodeRegistry';
|
||||
import type { PhaseNode } from "./visualProgrammingUI/nodes/PhaseNode";
|
||||
|
||||
/**
|
||||
* Reduces the graph into its phases' information and recursively calls their reducing function
|
||||
*/
|
||||
export function graphReducer() {
|
||||
const { nodes } = useFlowStore.getState();
|
||||
return orderPhaseNodeArray(nodes.filter((n) => n.type == 'phase') as PhaseNode [])
|
||||
.map((n) => {
|
||||
const reducer = NodeReduces['phase'];
|
||||
return reducer(n, nodes)
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Outputs the prepared program to the console and sends it to the backend
|
||||
*/
|
||||
export function runProgramm() {
|
||||
const phases = graphReducer();
|
||||
const program = {phases}
|
||||
console.log(JSON.stringify(program, null, 2));
|
||||
fetch(
|
||||
"http://localhost:8000/program",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify(program),
|
||||
}
|
||||
).then((res) => {
|
||||
if (!res.ok) throw new Error("Failed communicating with the backend.")
|
||||
console.log("Successfully sent the program to the backend.");
|
||||
|
||||
// store reduced program in global program store for further use in the UI
|
||||
// when the program was sent to the backend successfully:
|
||||
useProgramStore.getState().setProgramState(structuredClone(program));
|
||||
}).catch(() => console.log("Failed to send program to the backend."));
|
||||
console.log(program);
|
||||
}
|
||||
@@ -113,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 = ""
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
293
test/pages/monitoringPage/MonitoringPage.test.tsx
Normal file
293
test/pages/monitoringPage/MonitoringPage.test.tsx
Normal 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('✔️');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
229
test/pages/monitoringPage/MonitoringPageAPI.test.ts
Normal file
229
test/pages/monitoringPage/MonitoringPageAPI.test.ts
Normal 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));
|
||||
});
|
||||
});
|
||||
});
|
||||
226
test/pages/monitoringPage/MonitoringPageComponents.test.tsx
Normal file
226
test/pages/monitoringPage/MonitoringPageComponents.test.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
// Corrected Imports
|
||||
import {
|
||||
GestureControls,
|
||||
SpeechPresets,
|
||||
DirectSpeechInput,
|
||||
StatusList,
|
||||
RobotConnected
|
||||
} from '../../../src/pages/MonitoringPage/MonitoringPageComponents';
|
||||
|
||||
import * as MonitoringAPI from '../../../src/pages/MonitoringPage/MonitoringPageAPI';
|
||||
|
||||
// Mock the API Call function with the correct path
|
||||
jest.mock('../../../src/pages/MonitoringPage/MonitoringPageAPI', () => ({
|
||||
sendAPICall: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('MonitoringPageComponents', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('GestureControls', () => {
|
||||
test('renders and sends gesture command', () => {
|
||||
render(<GestureControls />);
|
||||
|
||||
fireEvent.change(screen.getByRole('combobox'), {
|
||||
target: { value: 'animations/Stand/Gestures/Hey_1' }
|
||||
});
|
||||
|
||||
// Click button
|
||||
fireEvent.click(screen.getByText('Actuate'));
|
||||
|
||||
// Expect the API to be called with that new value
|
||||
expect(MonitoringAPI.sendAPICall).toHaveBeenCalledWith('gesture', 'animations/Stand/Gestures/Hey_1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SpeechPresets', () => {
|
||||
test('renders buttons and sends speech command', () => {
|
||||
render(<SpeechPresets />);
|
||||
|
||||
const btn = screen.getByText('"Hello, I\'m Pepper"');
|
||||
fireEvent.click(btn);
|
||||
|
||||
expect(MonitoringAPI.sendAPICall).toHaveBeenCalledWith('speech', "Hello, I'm Pepper");
|
||||
});
|
||||
});
|
||||
|
||||
describe('DirectSpeechInput', () => {
|
||||
test('inputs text and sends on button click', () => {
|
||||
render(<DirectSpeechInput />);
|
||||
const input = screen.getByPlaceholderText('Type message...');
|
||||
|
||||
fireEvent.change(input, { target: { value: 'Custom text' } });
|
||||
fireEvent.click(screen.getByText('Send'));
|
||||
|
||||
expect(MonitoringAPI.sendAPICall).toHaveBeenCalledWith('speech', 'Custom text');
|
||||
expect(input).toHaveValue(''); // Should clear
|
||||
});
|
||||
|
||||
test('sends on Enter key', () => {
|
||||
render(<DirectSpeechInput />);
|
||||
const input = screen.getByPlaceholderText('Type message...');
|
||||
|
||||
fireEvent.change(input, { target: { value: 'Enter text' } });
|
||||
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
|
||||
|
||||
expect(MonitoringAPI.sendAPICall).toHaveBeenCalledWith('speech', 'Enter text');
|
||||
});
|
||||
|
||||
test('does not send empty text', () => {
|
||||
render(<DirectSpeechInput />);
|
||||
fireEvent.click(screen.getByText('Send'));
|
||||
expect(MonitoringAPI.sendAPICall).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('StatusList', () => {
|
||||
const mockSet = jest.fn();
|
||||
const items = [
|
||||
{ id: '1', name: 'Item 1' },
|
||||
{ id: '2', name: 'Item 2' }
|
||||
];
|
||||
|
||||
test('renders list items', () => {
|
||||
render(<StatusList title="Test List" items={items} type="goal" activeIds={{}} />);
|
||||
expect(screen.getByText('Test List')).toBeInTheDocument();
|
||||
expect(screen.getByText('Item 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Goals: click override on inactive item calls API', () => {
|
||||
render(
|
||||
<StatusList
|
||||
title="Goals"
|
||||
items={items}
|
||||
type="goal"
|
||||
activeIds={{}}
|
||||
setActiveIds={mockSet}
|
||||
/>
|
||||
);
|
||||
|
||||
// Click the X (inactive)
|
||||
const indicator = screen.getAllByText('❌')[0];
|
||||
fireEvent.click(indicator);
|
||||
|
||||
expect(MonitoringAPI.sendAPICall).toHaveBeenCalledWith('override', '1');
|
||||
expect(mockSet).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('Conditional Norms: click override on ACTIVE item unachieves', () => {
|
||||
render(
|
||||
<StatusList
|
||||
title="CN"
|
||||
items={items}
|
||||
type="cond_norm"
|
||||
activeIds={{ '1': true }}
|
||||
/>
|
||||
);
|
||||
|
||||
const indicator = screen.getByText('✔️'); // It is active
|
||||
fireEvent.click(indicator);
|
||||
|
||||
expect(MonitoringAPI.sendAPICall).toHaveBeenCalledWith('override_unachieve', '1');
|
||||
});
|
||||
|
||||
test('Current Goal highlighting', () => {
|
||||
render(
|
||||
<StatusList
|
||||
title="Goals"
|
||||
items={items}
|
||||
type="goal"
|
||||
activeIds={{}}
|
||||
currentGoalIndex={0}
|
||||
/>
|
||||
);
|
||||
// Using regex to handle the "(Current)" text
|
||||
expect(screen.getByText(/Item 1/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/(Current)/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('RobotConnected', () => {
|
||||
let mockEventSource: any;
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(window, 'EventSource', {
|
||||
writable: true,
|
||||
value: jest.fn().mockImplementation(() => ({
|
||||
close: jest.fn(),
|
||||
onmessage: null,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockEventSource = new window.EventSource('url');
|
||||
(window.EventSource as unknown as jest.Mock).mockClear();
|
||||
(window.EventSource as unknown as jest.Mock).mockImplementation(() => mockEventSource);
|
||||
});
|
||||
|
||||
test('displays disconnected initially', () => {
|
||||
render(<RobotConnected />);
|
||||
expect(screen.getByText('● Robot is disconnected')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('updates to connected when SSE receives true', async () => {
|
||||
render(<RobotConnected />);
|
||||
|
||||
act(() => {
|
||||
if(mockEventSource.onmessage) {
|
||||
mockEventSource.onmessage({ data: 'true' } as MessageEvent);
|
||||
}
|
||||
});
|
||||
|
||||
expect(await screen.findByText('● Robot is connected')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('handles invalid JSON gracefully', async () => {
|
||||
const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
|
||||
render(<RobotConnected />);
|
||||
|
||||
act(() => {
|
||||
if(mockEventSource.onmessage) {
|
||||
mockEventSource.onmessage({ data: 'invalid-json' } as MessageEvent);
|
||||
}
|
||||
});
|
||||
|
||||
// Should catch error and log it, state remains disconnected
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Ping message not in correct format:', 'invalid-json');
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('logs error if state update fails (inner catch block)', async () => {
|
||||
const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
// 1. Force useState to return a setter that throws an error
|
||||
const mockThrowingSetter = jest.fn(() => { throw new Error('Forced State Error'); });
|
||||
|
||||
// We use mockImplementation to return [currentState, throwingSetter]
|
||||
const useStateSpy = jest.spyOn(React, 'useState')
|
||||
.mockImplementation(() => [null, mockThrowingSetter]);
|
||||
|
||||
render(<RobotConnected />);
|
||||
|
||||
// 2. Trigger the event with VALID JSON ("true")
|
||||
// This passes the first JSON.parse try/catch,
|
||||
// but fails when calling setConnected(true) because of our mock.
|
||||
await act(async () => {
|
||||
if (mockEventSource.onmessage) {
|
||||
mockEventSource.onmessage({ data: 'true' } as MessageEvent);
|
||||
}
|
||||
});
|
||||
|
||||
// 3. Verify the specific error log from line 205
|
||||
expect(consoleSpy).toHaveBeenCalledWith("couldnt extract connected from incoming ping data");
|
||||
|
||||
// Cleanup spies
|
||||
useStateSpy.mockRestore();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
83
test/pages/simpleProgram/SimpleProgram.tsx
Normal file
83
test/pages/simpleProgram/SimpleProgram.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import SimpleProgram from "../../../src/pages/SimpleProgram/SimpleProgram";
|
||||
import useProgramStore from "../../../src/utils/programStore";
|
||||
|
||||
/**
|
||||
* Helper to preload the program store before rendering.
|
||||
*/
|
||||
function loadProgram(phases: Record<string, unknown>[]) {
|
||||
useProgramStore.getState().setProgramState({ phases });
|
||||
}
|
||||
|
||||
describe("SimpleProgram", () => {
|
||||
beforeEach(() => {
|
||||
loadProgram([]);
|
||||
});
|
||||
|
||||
test("shows empty state when no program is loaded", () => {
|
||||
render(<SimpleProgram />);
|
||||
expect(screen.getByText("No program loaded.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders first phase content", () => {
|
||||
loadProgram([
|
||||
{
|
||||
id: "phase-1",
|
||||
norms: [{ id: "n1", norm: "Be polite" }],
|
||||
goals: [{ id: "g1", description: "Finish task", achieved: true }],
|
||||
triggers: [{ id: "t1", label: "Keyword trigger" }],
|
||||
},
|
||||
]);
|
||||
|
||||
render(<SimpleProgram />);
|
||||
|
||||
expect(screen.getByText("Phase 1 / 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Be polite")).toBeInTheDocument();
|
||||
expect(screen.getByText("Finish task")).toBeInTheDocument();
|
||||
expect(screen.getByText("Keyword trigger")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("allows navigating between phases", () => {
|
||||
loadProgram([
|
||||
{
|
||||
id: "phase-1",
|
||||
norms: [],
|
||||
goals: [],
|
||||
triggers: [],
|
||||
},
|
||||
{
|
||||
id: "phase-2",
|
||||
norms: [{ id: "n2", norm: "Be careful" }],
|
||||
goals: [],
|
||||
triggers: [],
|
||||
},
|
||||
]);
|
||||
|
||||
render(<SimpleProgram />);
|
||||
|
||||
expect(screen.getByText("Phase 1 / 2")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText("Next ▶"));
|
||||
|
||||
expect(screen.getByText("Phase 2 / 2")).toBeInTheDocument();
|
||||
expect(screen.getByText("Be careful")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("prev button is disabled on first phase", () => {
|
||||
loadProgram([
|
||||
{ id: "phase-1", norms: [], goals: [], triggers: [] },
|
||||
]);
|
||||
|
||||
render(<SimpleProgram />);
|
||||
expect(screen.getByText("◀ Prev")).toBeDisabled();
|
||||
});
|
||||
|
||||
test("next button is disabled on last phase", () => {
|
||||
loadProgram([
|
||||
{ id: "phase-1", norms: [], goals: [], triggers: [] },
|
||||
]);
|
||||
|
||||
render(<SimpleProgram />);
|
||||
expect(screen.getByText("Next ▶")).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
Reference in New Issue
Block a user