Compare commits

..

2 Commits

Author SHA1 Message Date
Twirre Meulenbelt
1b0d678826 feat: use SVG for button icons
ref: N25B-400
2026-01-26 10:09:44 +01:00
Pim Hutting
470140ebdd Merge branch 'demo' into feat/monitoringpage 2026-01-19 15:03:32 +01:00
20 changed files with 110 additions and 231 deletions

View File

@@ -0,0 +1,5 @@
export default function Next({ fill }: { fill?: string }) {
return <svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill={fill ?? "canvas"}>
<path d="M664.07-224.93v-510.14h91v510.14h-91Zm-459.14 0v-510.14L587.65-480 204.93-224.93Z"/>
</svg>;
}

View File

@@ -0,0 +1,5 @@
export default function Pause({ fill }: { fill?: string }) {
return <svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill={fill ?? "canvas"}>
<path d="M556.17-185.41v-589.18h182v589.18h-182Zm-334.34 0v-589.18h182v589.18h-182Z"/>
</svg>;
}

View File

@@ -0,0 +1,5 @@
export default function Play({ fill }: { fill?: string }) {
return <svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill={fill ?? "canvas"}>
<path d="M311.87-185.41v-589.18L775.07-480l-463.2 294.59Z"/>
</svg>;
}

View File

@@ -0,0 +1,5 @@
export default function Redo({ fill }: { fill?: string }) {
return <svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill={fill ?? "canvas"}>
<path d="M390.98-191.87q-98.44 0-168.77-65.27-70.34-65.27-70.34-161.43 0-96.15 70.34-161.54 70.33-65.39 168.77-65.39h244.11l-98.98-98.98 63.65-63.65L808.13-600 599.76-391.87l-63.65-63.65 98.98-98.98H390.98q-60.13 0-104.12 38.92-43.99 38.93-43.99 96.78 0 57.84 43.99 96.89 43.99 39.04 104.12 39.04h286.15v91H390.98Z"/>
</svg>;
}

View File

@@ -0,0 +1,5 @@
export default function Replay({ fill }: { fill?: string }) {
return <svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill={fill ?? "canvas"}>
<path d="M480.05-70.43q-76.72 0-143.78-29.1-67.05-29.1-116.75-78.8-49.69-49.69-78.79-116.75-29.1-67.05-29.1-143.49h91q0 115.81 80.73 196.47Q364.1-161.43 480-161.43q115.8 0 196.47-80.74 80.66-80.73 80.66-196.63 0-115.81-80.73-196.47-80.74-80.66-196.64-80.66h-6.24l60.09 60.08-58.63 60.63-166.22-166.21 166.22-166.22 58.63 60.87-59.85 59.85h6q76.74 0 143.76 29.09 67.02 29.1 116.84 78.8 49.81 49.69 78.91 116.64 29.1 66.95 29.1 143.61 0 76.66-29.1 143.71-29.1 67.06-78.79 116.75-49.7 49.7-116.7 78.8-67.01 29.1-143.73 29.1Z"/>
</svg>;
}

View File

@@ -28,6 +28,22 @@
position: static; /* ensures it scrolls away */ position: static; /* ensures it scrolls away */
} }
.controlsButtons {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
gap: .25rem;
max-width: 260px;
flex-wrap: wrap;
button {
display: flex;
justify-content: center;
align-items: center;
}
}
.phaseProgress { .phaseProgress {
margin-top: 0.5rem; margin-top: 0.5rem;
} }
@@ -77,6 +93,11 @@
color: white; color: white;
} }
.restartPhase{
background-color: rgb(255, 123, 0);
color: white;
}
.restartExperiment{ .restartExperiment{
background-color: red; background-color: red;
color: white; color: white;

View File

@@ -30,6 +30,11 @@ import {
StatusList, StatusList,
RobotConnected RobotConnected
} from './MonitoringPageComponents'; } from './MonitoringPageComponents';
import Replay from "../../components/Icons/Replay.tsx";
import Pause from "../../components/Icons/Pause.tsx";
import Play from "../../components/Icons/Play.tsx";
import Next from "../../components/Icons/Next.tsx";
import Redo from "../../components/Icons/Redo.tsx";
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
// 1. State management // 1. State management
@@ -101,6 +106,7 @@ function useExperimentLogic() {
}, [getPhaseIds, getGoalsInPhase, phaseIds, phaseIndex, phaseNames]); }, [getPhaseIds, getGoalsInPhase, phaseIds, phaseIndex, phaseNames]);
const handleStatusUpdate = useCallback((data: unknown) => { const handleStatusUpdate = useCallback((data: unknown) => {
const payload = data as CondNormsStateUpdate; const payload = data as CondNormsStateUpdate;
if (payload.type !== 'cond_norms_state_update') return; if (payload.type !== 'cond_norms_state_update') return;
@@ -140,7 +146,7 @@ function useExperimentLogic() {
} }
}, [setProgramState]); }, [setProgramState]);
const handleControlAction = async (action: "pause" | "play" | "nextPhase") => { const handleControlAction = async (action: "pause" | "play" | "nextPhase" | "resetPhase") => {
try { try {
setLoading(true); setLoading(true);
switch (action) { switch (action) {
@@ -155,6 +161,7 @@ function useExperimentLogic() {
case "nextPhase": case "nextPhase":
await nextPhase(); await nextPhase();
break; break;
// Case for resetPhase if implemented in API
} }
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@@ -222,7 +229,7 @@ function ControlPanel({
}: { }: {
loading: boolean, loading: boolean,
isPlaying: boolean, isPlaying: boolean,
onAction: (a: "pause" | "play" | "nextPhase") => void, onAction: (a: "pause" | "play" | "nextPhase" | "resetPhase") => void,
onReset: () => void onReset: () => void
}) { }) {
return ( return (
@@ -233,25 +240,31 @@ function ControlPanel({
className={!isPlaying ? styles.pausePlayActive : styles.pausePlayInactive} className={!isPlaying ? styles.pausePlayActive : styles.pausePlayInactive}
onClick={() => onAction("pause")} onClick={() => onAction("pause")}
disabled={loading} disabled={loading}
></button> ><Pause /></button>
<button <button
className={isPlaying ? styles.pausePlayActive : styles.pausePlayInactive} className={isPlaying ? styles.pausePlayActive : styles.pausePlayInactive}
onClick={() => onAction("play")} onClick={() => onAction("play")}
disabled={loading} disabled={loading}
></button> ><Play /></button>
<button <button
className={styles.next} className={styles.next}
onClick={() => onAction("nextPhase")} onClick={() => onAction("nextPhase")}
disabled={loading} disabled={loading}
></button> ><Next /></button>
<button
className={styles.restartPhase}
onClick={() => onAction("resetPhase")}
disabled={loading}
><Redo /></button>
<button <button
className={styles.restartExperiment} className={styles.restartExperiment}
onClick={onReset} onClick={onReset}
disabled={loading} disabled={loading}
></button> ><Replay /></button>
</div> </div>
</div> </div>
); );
@@ -271,17 +284,14 @@ function PhaseDashboard({
setActiveIds: React.Dispatch<React.SetStateAction<Record<string, boolean>>>, setActiveIds: React.Dispatch<React.SetStateAction<Record<string, boolean>>>,
goalIndex: number goalIndex: number
}) { }) {
const getGoalsWithDepth = useProgramStore((s) => s.getGoalsWithDepth); const getGoals = useProgramStore((s) => s.getGoalsInPhase);
const getTriggers = useProgramStore((s) => s.getTriggersInPhase); const getTriggers = useProgramStore((s) => s.getTriggersInPhase);
const getNorms = useProgramStore((s) => s.getNormsInPhase); const getNorms = useProgramStore((s) => s.getNormsInPhase);
// Prepare data view models // Prepare data view models
const goals = getGoalsWithDepth(phaseId).map((g) => ({ const goals = (getGoals(phaseId) as GoalNode[]).map(g => ({
...g, ...g,
id: g.id as string, achieved: activeIds[g.id] ?? false,
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 => ({ const triggers = (getTriggers(phaseId) as TriggerNode[]).map(t => ({

View File

@@ -32,6 +32,16 @@ export async function nextPhase(): Promise<void> {
} }
/**
* 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 * Sends an API call to the CB for going to pause experiment
*/ */

View File

@@ -91,14 +91,13 @@ export const DirectSpeechInput: React.FC = () => {
}; };
// --- interface for goals/triggers/norms/conditional norms --- // --- interface for goals/triggers/norms/conditional norms ---
export type StatusItem = { type StatusItem = {
id?: string | number; id?: string | number;
achieved?: boolean; achieved?: boolean;
description?: string; description?: string;
label?: string; label?: string;
norm?: string; norm?: string;
name?: string; name?: string;
level?: number;
}; };
interface StatusListProps { interface StatusListProps {
@@ -130,7 +129,7 @@ export const StatusList: React.FC<StatusListProps> = ({
const isCurrentGoal = type === 'goal' && idx === currentGoalIndex; const isCurrentGoal = type === 'goal' && idx === currentGoalIndex;
const canOverride = (showIndicator && !isActive) || (type === 'cond_norm' && isActive); const canOverride = (showIndicator && !isActive) || (type === 'cond_norm' && isActive);
const indentation = (item.level || 0) * 20;
const handleOverrideClick = () => { const handleOverrideClick = () => {
if (!canOverride) return; if (!canOverride) return;
@@ -148,10 +147,7 @@ export const StatusList: React.FC<StatusListProps> = ({
}; };
return ( return (
<li key={item.id ?? idx} <li key={item.id ?? idx} className={styles.statusItem}>
className={styles.statusItem}
style={{ paddingLeft: `${indentation}px` }}
>
{showIndicator && ( {showIndicator && (
<span <span
className={`${styles.statusIndicator} ${isActive ? styles.active : styles.inactive} ${canOverride ? styles.clickable : ''}`} className={`${styles.statusIndicator} ${isActive ? styles.active : styles.inactive} ${canOverride ? styles.clickable : ''}`}

View File

@@ -119,7 +119,7 @@ const VisProgUI = () => {
<SaveLoadPanel></SaveLoadPanel> <SaveLoadPanel></SaveLoadPanel>
</Panel> </Panel>
<Panel position="bottom-center"> <Panel position="bottom-center">
<button onClick={() => undo()}>Undo</button> <button onClick={() => undo()}>undo</button>
<button onClick={() => redo()}>Redo</button> <button onClick={() => redo()}>Redo</button>
</Panel> </Panel>
<Controls/> <Controls/>
@@ -175,7 +175,7 @@ function VisProgPage() {
return ( return (
<> <>
<VisualProgrammingUI/> <VisualProgrammingUI/>
<button onClick={runProgram}>Run Program</button> <button onClick={runProgram}>run program</button>
</> </>
) )
} }

View File

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

View File

@@ -9,7 +9,6 @@ import {
type XYPosition, type XYPosition,
} from '@xyflow/react'; } from '@xyflow/react';
import '@xyflow/react/dist/style.css'; import '@xyflow/react/dist/style.css';
import {type ConnectionContext, validateConnectionWithRules} from "./HandleRuleLogic.ts";
import type { FlowState } from './VisProgTypes'; import type { FlowState } from './VisProgTypes';
import { import {
NodeDefaults, NodeDefaults,
@@ -130,41 +129,7 @@ const useFlowStore = create<FlowState>(UndoRedo((set, get) => ({
* Handles reconnecting an edge between nodes. * Handles reconnecting an edge between nodes.
*/ */
onReconnect: (oldEdge, newConnection) => { onReconnect: (oldEdge, newConnection) => {
get().edgeReconnectSuccessful = true;
function createContext(
source: {id: string, handleId: string},
target: {id: string, handleId: string}
) : ConnectionContext {
const edges = get().edges;
const targetConnections = edges.filter(edge => edge.target === target.id && edge.targetHandle === target.handleId).length
return {
connectionCount: targetConnections,
source: source,
target: target
}
}
// connection validation
const context: ConnectionContext = oldEdge.source === newConnection.source
? createContext({id: newConnection.source, handleId: newConnection.sourceHandle!}, {id: newConnection.target, handleId: newConnection.targetHandle!})
: createContext({id: newConnection.target, handleId: newConnection.targetHandle!}, {id: newConnection.source, handleId: newConnection.sourceHandle!});
const result = validateConnectionWithRules(
newConnection,
context
);
if (!result.isSatisfied) {
set({
edges: get().edges.map(e =>
e.id === oldEdge.id ? oldEdge : e
),
});
return;
}
// further reconnect logic
set({ edgeReconnectSuccessful: true });
set({ edges: reconnectEdge(oldEdge, newConnection, get().edges) }); set({ edges: reconnectEdge(oldEdge, newConnection, get().edges) });
// We make sure to perform any required data updates on the newly reconnected nodes // We make sure to perform any required data updates on the newly reconnected nodes
@@ -223,7 +188,7 @@ const useFlowStore = create<FlowState>(UndoRedo((set, get) => ({
// Let's find our node to check if they have a special deletion function // Let's find our node to check if they have a special deletion function
const ourNode = get().nodes.find((n)=>n.id==nodeId); const ourNode = get().nodes.find((n)=>n.id==nodeId);
const ourFunction = Object.entries(NodeDeletes).find(([t])=>t==ourNode?.type)?.[1] const ourFunction = Object.entries(NodeDeletes).find(([t])=>t==ourNode?.type)?.[1]
// If there's no function, OR, our function tells us we can delete it, let's do so... // If there's no function, OR, our function tells us we can delete it, let's do so...
if (ourFunction == undefined || ourFunction()) { if (ourFunction == undefined || ourFunction()) {
set({ set({

View File

@@ -10,7 +10,6 @@ import {allowOnlyConnectionsFromHandle} from "../HandleRules.ts";
import useFlowStore from '../VisProgStores.tsx'; import useFlowStore from '../VisProgStores.tsx';
import { TextField } from '../../../../components/TextField.tsx'; import { TextField } from '../../../../components/TextField.tsx';
import { MultilineTextField } from '../../../../components/MultilineTextField.tsx'; import { MultilineTextField } from '../../../../components/MultilineTextField.tsx';
import {noMatchingLeftRightBelief} from "./BeliefGlobals.ts";
/** /**
* The default data structure for a BasicBelief node * The default data structure for a BasicBelief node
@@ -32,12 +31,11 @@ export type BasicBeliefNodeData = {
}; };
// These are all the types a basic belief could be. // These are all the types a basic belief could be.
export type BasicBeliefType = Keyword | Semantic | DetectedObject | Emotion | Face export type BasicBeliefType = Keyword | Semantic | DetectedObject | Emotion
type Keyword = { type: "keyword", id: string, value: string, label: "Keyword said:"}; 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 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 DetectedObject = { type: "object", id: string, value: string, label: "Object found:"};
type Emotion = { type: "emotion", id: string, value: string, label: "Emotion recognised:"}; 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> export type BasicBeliefNode = Node<BasicBeliefNodeData>
@@ -157,7 +155,6 @@ export default function BasicBeliefNode(props: NodeProps<BasicBeliefNode>) {
<option value="semantic">Detected with LLM:</option> <option value="semantic">Detected with LLM:</option>
<option value="object">Object found:</option> <option value="object">Object found:</option>
<option value="emotion">Emotion recognised:</option> <option value="emotion">Emotion recognised:</option>
<option value="face">Face detected</option>
</select> </select>
{wrapping} {wrapping}
{data.belief.type === "emotion" && ( {data.belief.type === "emotion" && (
@@ -192,8 +189,7 @@ export default function BasicBeliefNode(props: NodeProps<BasicBeliefNode>) {
</div> </div>
)} )}
<MultiConnectionHandle type="source" position={Position.Right} id="source" rules={[ <MultiConnectionHandle type="source" position={Position.Right} id="source" rules={[
noMatchingLeftRightBelief, allowOnlyConnectionsFromHandle([{nodeType:"trigger",handleId:"TriggerBeliefs"}, {nodeType:"norm",handleId:"NormBeliefs"},{nodeType:"InferredBelief",handleId:"inferred_belief"}]),
allowOnlyConnectionsFromHandle([{nodeType:"trigger",handleId:"TriggerBeliefs"}, {nodeType:"norm",handleId:"NormBeliefs"},{nodeType:"InferredBelief",handleId:"inferred_belief"}]),
]}/> ]}/>
</div> </div>
</> </>
@@ -226,10 +222,6 @@ export function BasicBeliefReduce(node: Node, _nodes: Node[]) {
result["name"] = data.belief.value; result["name"] = data.belief.value;
result["description"] = data.belief.description; result["description"] = data.belief.description;
break; break;
case "face":
result["face_present"] = true;
break;
default: default:
break; break;
} }

View File

@@ -5,7 +5,7 @@ import type { InferredBeliefNodeData } from "./InferredBeliefNode.tsx";
* Default data for this node * Default data for this node
*/ */
export const InferredBeliefNodeDefaults: InferredBeliefNodeData = { export const InferredBeliefNodeDefaults: InferredBeliefNodeData = {
label: "AND/OR", label: "Inferred Belief",
droppable: true, droppable: true,
inferredBelief: { inferredBelief: {
left: undefined, left: undefined,

View File

@@ -50,9 +50,9 @@ export default function TriggerNode(props: NodeProps<TriggerNode>) {
const setName= (value: string) => { const setName= (value: string) => {
updateNodeData(props.id, {...data, name: value}) updateNodeData(props.id, {...data, name: value})
} }
return <> return <>
<Toolbar nodeId={props.id} allowDelete={true}/> <Toolbar nodeId={props.id} allowDelete={true}/>
<div className={`${styles.defaultNode} ${styles.nodeTrigger} flex-col gap-sm`}> <div className={`${styles.defaultNode} ${styles.nodeTrigger} flex-col gap-sm`}>
<TextField <TextField
@@ -70,9 +70,9 @@ export default function TriggerNode(props: NodeProps<TriggerNode>) {
type="target" type="target"
position={Position.Bottom} position={Position.Bottom}
id="TriggerBeliefs" id="TriggerBeliefs"
style={{ left: '40%' }} style={{ left: '40%' }}
rules={[ rules={[
allowOnlyConnectionsFromType(['basic_belief']), allowOnlyConnectionsFromType(['basic_belief', "inferred_belief"]),
]} ]}
/> />
@@ -136,7 +136,7 @@ export function TriggerConnectionTarget(_thisNode: Node, _sourceNodeId: string)
const otherNode = nodes.find((x) => x.id === _sourceNodeId) const otherNode = nodes.find((x) => x.id === _sourceNodeId)
if (!otherNode) return; if (!otherNode) return;
if (otherNode.type === 'basic_belief' /* TODO: Add the option for an inferred belief */) { if (otherNode.type === 'basic_belief'|| otherNode.type ==='inferred_belief') {
data.condition = _sourceNodeId; data.condition = _sourceNodeId;
} }
@@ -172,7 +172,7 @@ export function TriggerDisconnectionTarget(_thisNode: Node, _sourceNodeId: strin
const data = _thisNode.data as TriggerNodeData; const data = _thisNode.data as TriggerNodeData;
// remove if the target of disconnection was our condition // remove if the target of disconnection was our condition
if (_sourceNodeId == data.condition) data.condition = undefined if (_sourceNodeId == data.condition) data.condition = undefined
data.plan = deleteGoalInPlanByID(structuredClone(data.plan) as Plan, _sourceNodeId) data.plan = deleteGoalInPlanByID(structuredClone(data.plan) as Plan, _sourceNodeId)
} }

View File

@@ -3,8 +3,6 @@ import {create} from "zustand";
// the type of a reduced program // the type of a reduced program
export type ReducedProgram = { phases: Record<string, unknown>[] }; export type ReducedProgram = { phases: Record<string, unknown>[] };
export type GoalWithDepth = Record<string, unknown> & { level: number };
/** /**
* the type definition of the programStore * the type definition of the programStore
*/ */
@@ -20,7 +18,6 @@ export type ProgramState = {
getPhaseNames: () => string[]; getPhaseNames: () => string[];
getNormsInPhase: (currentPhaseId: string) => Record<string, unknown>[]; getNormsInPhase: (currentPhaseId: string) => Record<string, unknown>[];
getGoalsInPhase: (currentPhaseId: string) => Record<string, unknown>[]; getGoalsInPhase: (currentPhaseId: string) => Record<string, unknown>[];
getGoalsWithDepth: (currentPhaseId: string) => GoalWithDepth[];
getTriggersInPhase: (currentPhaseId: string) => Record<string, unknown>[]; getTriggersInPhase: (currentPhaseId: string) => Record<string, unknown>[];
// if more specific utility functions are needed they can be added here: // if more specific utility functions are needed they can be added here:
} }
@@ -73,50 +70,6 @@ const useProgramStore = create<ProgramState>((set, get) => ({
} }
throw new Error(`phase with id:"${currentPhaseId}" not found`) 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 * gets the triggers for the provided phase
*/ */

View File

@@ -51,7 +51,6 @@ describe('MonitoringPage', () => {
const mockGetPhaseNames = jest.fn(); const mockGetPhaseNames = jest.fn();
const mockGetNorms = jest.fn(); const mockGetNorms = jest.fn();
const mockGetGoals = jest.fn(); const mockGetGoals = jest.fn();
const mockGetGoalsWithDepth = jest.fn();
const mockGetTriggers = jest.fn(); const mockGetTriggers = jest.fn();
const mockSetProgramState = jest.fn(); const mockSetProgramState = jest.fn();
@@ -66,7 +65,6 @@ describe('MonitoringPage', () => {
getNormsInPhase: mockGetNorms, getNormsInPhase: mockGetNorms,
getGoalsInPhase: mockGetGoals, getGoalsInPhase: mockGetGoals,
getTriggersInPhase: mockGetTriggers, getTriggersInPhase: mockGetTriggers,
getGoalsWithDepth: mockGetGoalsWithDepth,
setProgramState: mockSetProgramState, setProgramState: mockSetProgramState,
}; };
return selector(state); return selector(state);
@@ -83,11 +81,7 @@ describe('MonitoringPage', () => {
// Default mock return values // Default mock return values
mockGetPhaseIds.mockReturnValue(['phase-1', 'phase-2']); mockGetPhaseIds.mockReturnValue(['phase-1', 'phase-2']);
mockGetPhaseNames.mockReturnValue(['Intro', 'Main']); mockGetPhaseNames.mockReturnValue(['Intro', 'Main']);
mockGetGoals.mockReturnValue([{ id: 'g1', name: 'Goal 1'}, { id: 'g2', name: 'Goal 2'}]); 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' }]); mockGetTriggers.mockReturnValue([{ id: 't1', name: 'Trigger 1' }]);
mockGetNorms.mockReturnValue([ mockGetNorms.mockReturnValue([
{ id: 'n1', norm: 'Norm 1', condition: null }, { id: 'n1', norm: 'Norm 1', condition: null },

View File

@@ -1,7 +1,8 @@
import { renderHook, act, cleanup } from '@testing-library/react'; import { renderHook, act, cleanup } from '@testing-library/react';
import { import {
sendAPICall, sendAPICall,
nextPhase, nextPhase,
resetPhase,
pauseExperiment, pauseExperiment,
playExperiment, playExperiment,
useExperimentLogger, useExperimentLogger,
@@ -115,6 +116,14 @@ describe('MonitoringPageAPI', () => {
); );
}); });
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 () => { test('pauseExperiment sends correct params', async () => {
await pauseExperiment(); await pauseExperiment();
expect(globalThis.fetch).toHaveBeenCalledWith( expect(globalThis.fetch).toHaveBeenCalledWith(

View File

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

View File

@@ -115,89 +115,6 @@ describe('useProgramStore', () => {
}); });
}); });
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', () => { it('should return the names of all phases in the program', () => {
// Define a program specifically with names for this test // Define a program specifically with names for this test
const programWithNames: ReducedProgram = { const programWithNames: ReducedProgram = {