Merge branch 'feat/simple-program-page' of git.science.uu.nl:ics/sp/2025/n25b/pepperplus-ui into feat/monitoringpage
This commit is contained in:
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;
|
||||
@@ -7,14 +7,16 @@ import {
|
||||
MarkerType,
|
||||
} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import {useEffect} from "react";
|
||||
import {useEffect, useState} from "react";
|
||||
import {useShallow} from 'zustand/react/shallow';
|
||||
import useProgramStore from "../../utils/programStore.ts";
|
||||
import {DndToolbar} from './visualProgrammingUI/components/DragDropSidebar.tsx';
|
||||
import useFlowStore from './visualProgrammingUI/VisProgStores.tsx';
|
||||
import type {FlowState} from './visualProgrammingUI/VisProgTypes.tsx';
|
||||
import styles from './VisProg.module.css'
|
||||
import { NodeReduces, NodeTypes } from './visualProgrammingUI/NodeRegistry.ts';
|
||||
import SaveLoadPanel from './visualProgrammingUI/components/SaveLoadPanel.tsx';
|
||||
import SimpleProgram from "../SimpleProgram/SimpleProgram.tsx";
|
||||
|
||||
// --| config starting params for flow |--
|
||||
|
||||
@@ -138,7 +140,7 @@ function VisualProgrammingUI() {
|
||||
}
|
||||
|
||||
// currently outputs the prepared program to the console
|
||||
function runProgram() {
|
||||
function runProgramm() {
|
||||
const phases = graphReducer();
|
||||
const program = {phases}
|
||||
console.log(JSON.stringify(program, null, 2));
|
||||
@@ -152,6 +154,10 @@ function runProgram() {
|
||||
).then((res) => {
|
||||
if (!res.ok) throw new Error("Failed communicating with the backend.")
|
||||
console.log("Successfully sent the program to the backend.");
|
||||
|
||||
// store reduced program in global program store for further use in the UI
|
||||
// when the program was sent to the backend successfully:
|
||||
useProgramStore.getState().setProgramState(structuredClone(program));
|
||||
}).catch(() => console.log("Failed to send program to the backend."));
|
||||
}
|
||||
|
||||
@@ -174,6 +180,27 @@ function graphReducer() {
|
||||
* @constructor
|
||||
*/
|
||||
function VisProgPage() {
|
||||
const [showSimpleProgram, setShowSimpleProgram] = useState(false);
|
||||
const setProgramState = useProgramStore((state) => state.setProgramState);
|
||||
|
||||
const runProgram = () => {
|
||||
const phases = graphReducer(); // reduce graph
|
||||
setProgramState({ phases }); // <-- save to store
|
||||
setShowSimpleProgram(true); // show SimpleProgram
|
||||
runProgramm(); // send to backend if needed
|
||||
};
|
||||
|
||||
if (showSimpleProgram) {
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => setShowSimpleProgram(false)}>
|
||||
Back to Editor ◀
|
||||
</button>
|
||||
<SimpleProgram/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<VisualProgrammingUI/>
|
||||
|
||||
@@ -45,7 +45,7 @@ const initialNodes : Node[] = [
|
||||
createNode('start', 'start', {x: 100, y: 100}, {label: "Start"}, false),
|
||||
createNode('end', 'end', {x: 500, y: 100}, {label: "End"}, false),
|
||||
createNode('phase-1', 'phase', {x:200, y:100}, {label: "Phase 1", children : []}),
|
||||
createNode('norms-1', 'norm', {x:-200, y:100}, {label: "Initial Norms", normList: ["Be a robot", "get good"]}),
|
||||
createNode('norms-1', 'norm', {x:-200, y:100}, {label: "Initial Norms", normList: ["Be a robot", "get good"], critical:false}),
|
||||
];
|
||||
|
||||
// * Initial edges * /
|
||||
|
||||
@@ -8,4 +8,5 @@ export const NormNodeDefaults: NormNodeData = {
|
||||
droppable: true,
|
||||
norm: "",
|
||||
hasReduce: true,
|
||||
critical: false,
|
||||
};
|
||||
@@ -21,6 +21,7 @@ export type NormNodeData = {
|
||||
droppable: boolean;
|
||||
norm: string;
|
||||
hasReduce: boolean;
|
||||
critical: boolean;
|
||||
};
|
||||
|
||||
export type NormNode = Node<NormNodeData>
|
||||
@@ -35,11 +36,16 @@ export default function NormNode(props: NodeProps<NormNode>) {
|
||||
const {updateNodeData} = useFlowStore();
|
||||
|
||||
const text_input_id = `norm_${props.id}_text_input`;
|
||||
const checkbox_id = `goal_${props.id}_checkbox`;
|
||||
|
||||
const setValue = (value: string) => {
|
||||
updateNodeData(props.id, {norm: value});
|
||||
}
|
||||
|
||||
const setCritical = (value: boolean) => {
|
||||
updateNodeData(props.id, {...data, critical: value});
|
||||
}
|
||||
|
||||
return <>
|
||||
<Toolbar nodeId={props.id} allowDelete={true}/>
|
||||
<div className={`${styles.defaultNode} ${styles.nodeNorm}`}>
|
||||
@@ -52,6 +58,15 @@ export default function NormNode(props: NodeProps<NormNode>) {
|
||||
placeholder={"Pepper should ..."}
|
||||
/>
|
||||
</div>
|
||||
<div className={"flex-row gap-md align-center"}>
|
||||
<label htmlFor={checkbox_id}>Critical:</label>
|
||||
<input
|
||||
id={checkbox_id}
|
||||
type={"checkbox"}
|
||||
checked={data.critical || false}
|
||||
onChange={(e) => setCritical(e.target.checked)}
|
||||
/>
|
||||
</div>
|
||||
<Handle type="source" position={Position.Right} id="norms"/>
|
||||
</div>
|
||||
</>;
|
||||
@@ -69,6 +84,7 @@ export function NormReduce(node: Node, _nodes: Node[]) {
|
||||
id: node.id,
|
||||
label: data.label,
|
||||
norm: data.norm,
|
||||
critical: data.critical,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
81
src/utils/programStore.ts
Normal file
81
src/utils/programStore.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import {create} from "zustand";
|
||||
|
||||
// the type of a reduced program
|
||||
export type ReducedProgram = { phases: Record<string, unknown>[] };
|
||||
|
||||
/**
|
||||
* the type definition of the programStore
|
||||
*/
|
||||
export type ProgramState = {
|
||||
// Basic store functionality:
|
||||
currentProgram: ReducedProgram;
|
||||
setProgramState: (state: ReducedProgram) => void;
|
||||
getProgramState: () => ReducedProgram;
|
||||
|
||||
// Utility functions:
|
||||
// to avoid having to manually go through the entire state for every instance where data is required
|
||||
getPhaseIds: () => string[];
|
||||
getNormsInPhase: (currentPhaseId: string) => Record<string, unknown>[];
|
||||
getGoalsInPhase: (currentPhaseId: string) => Record<string, unknown>[];
|
||||
getTriggersInPhase: (currentPhaseId: string) => Record<string, unknown>[];
|
||||
// if more specific utility functions are needed they can be added here:
|
||||
}
|
||||
|
||||
/**
|
||||
* the ProgramStore can be used to access all information of the most recently sent program,
|
||||
* it contains basic functions to set and get the current program.
|
||||
* And it contains some utility functions that allow you to easily gain access
|
||||
* to the norms, triggers and goals of a specific phase.
|
||||
*/
|
||||
const useProgramStore = create<ProgramState>((set, get) => ({
|
||||
currentProgram: { phases: [] as Record<string, unknown>[]},
|
||||
/**
|
||||
* sets the current program by cloning the provided program using a structuredClone
|
||||
*/
|
||||
setProgramState: (program: ReducedProgram) => set({currentProgram: structuredClone(program)}),
|
||||
/**
|
||||
* gets the current program
|
||||
*/
|
||||
getProgramState: () => get().currentProgram,
|
||||
|
||||
// utility functions:
|
||||
/**
|
||||
* gets the ids of all phases in the program
|
||||
*/
|
||||
getPhaseIds: () => get().currentProgram.phases.map(entry => entry["id"] as string),
|
||||
/**
|
||||
* gets the norms for the provided phase
|
||||
*/
|
||||
getNormsInPhase: (currentPhaseId) => {
|
||||
const program = get().currentProgram;
|
||||
const phase = program.phases.find(val => val["id"] === currentPhaseId);
|
||||
if (phase) {
|
||||
return phase["norms"] as Record<string, unknown>[];
|
||||
}
|
||||
throw new Error(`phase with id:"${currentPhaseId}" not found`)
|
||||
},
|
||||
/**
|
||||
* gets the goals for the provided phase
|
||||
*/
|
||||
getGoalsInPhase: (currentPhaseId) => {
|
||||
const program = get().currentProgram;
|
||||
const phase = program.phases.find(val => val["id"] === currentPhaseId);
|
||||
if (phase) {
|
||||
return phase["goals"] as Record<string, unknown>[];
|
||||
}
|
||||
throw new Error(`phase with id:"${currentPhaseId}" not found`)
|
||||
},
|
||||
/**
|
||||
* gets the triggers for the provided phase
|
||||
*/
|
||||
getTriggersInPhase: (currentPhaseId) => {
|
||||
const program = get().currentProgram;
|
||||
const phase = program.phases.find(val => val["id"] === currentPhaseId);
|
||||
if (phase) {
|
||||
return phase["triggers"] as Record<string, unknown>[];
|
||||
}
|
||||
throw new Error(`phase with id:"${currentPhaseId}" not found`)
|
||||
}
|
||||
}));
|
||||
|
||||
export default useProgramStore;
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -98,6 +98,7 @@ describe('NormNode', () => {
|
||||
droppable: true,
|
||||
norm: '',
|
||||
hasReduce: true,
|
||||
critical: false
|
||||
},
|
||||
};
|
||||
|
||||
@@ -627,6 +628,54 @@ describe('NormNode', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should properly update the store when editing critical checkbox', async () => {
|
||||
const mockNode: Node = {
|
||||
id: 'norm-1',
|
||||
type: 'norm',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: 'Test Norm',
|
||||
droppable: true,
|
||||
norm: '',
|
||||
hasReduce: true,
|
||||
critical: false,
|
||||
},
|
||||
};
|
||||
|
||||
useFlowStore.setState({
|
||||
nodes: [mockNode],
|
||||
edges: [],
|
||||
});
|
||||
|
||||
renderWithProviders(
|
||||
<NormNode
|
||||
id={mockNode.id}
|
||||
type={mockNode.type as string}
|
||||
data={mockNode.data as any}
|
||||
selected={false}
|
||||
isConnectable={true}
|
||||
zIndex={0}
|
||||
dragging={false}
|
||||
selectable={true}
|
||||
deletable={true}
|
||||
draggable={true}
|
||||
positionAbsoluteX={0}
|
||||
positionAbsoluteY={0}
|
||||
/>
|
||||
);
|
||||
|
||||
const checkbox = screen.getByLabelText('Critical:');
|
||||
await user.click(checkbox);
|
||||
|
||||
await waitFor(() => {
|
||||
const state = useFlowStore.getState();
|
||||
expect(state.nodes).toHaveLength(1);
|
||||
expect(state.nodes[0].id).toBe('norm-1');
|
||||
expect(state.nodes[0].data.norm).toBe('');
|
||||
expect(state.nodes[0].data.critical).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should not affect other nodes when updating one norm node', async () => {
|
||||
const norm1: Node = {
|
||||
id: 'norm-1',
|
||||
|
||||
@@ -2,6 +2,11 @@ import '@testing-library/jest-dom';
|
||||
import { cleanup } from '@testing-library/react';
|
||||
import useFlowStore from '../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx';
|
||||
|
||||
if (!globalThis.structuredClone) {
|
||||
globalThis.structuredClone = (obj: any) => {
|
||||
return JSON.parse(JSON.stringify(obj));
|
||||
};
|
||||
}
|
||||
|
||||
// To make sure that the tests are working, it's important that you are using
|
||||
// this implementation of ResizeObserver and DOMMatrixReadOnly
|
||||
|
||||
100
test/utils/programStore.test.ts
Normal file
100
test/utils/programStore.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import useProgramStore, {type ReducedProgram} from "../../src/utils/programStore.ts";
|
||||
|
||||
|
||||
describe('useProgramStore', () => {
|
||||
beforeEach(() => {
|
||||
// Reset store before each test
|
||||
useProgramStore.setState({
|
||||
currentProgram: { phases: [] },
|
||||
});
|
||||
});
|
||||
|
||||
const mockProgram: ReducedProgram = {
|
||||
phases: [
|
||||
{
|
||||
id: 'phase-1',
|
||||
norms: [{ id: 'norm-1' }],
|
||||
goals: [{ id: 'goal-1' }],
|
||||
triggers: [{ id: 'trigger-1' }],
|
||||
},
|
||||
{
|
||||
id: 'phase-2',
|
||||
norms: [{ id: 'norm-2' }],
|
||||
goals: [{ id: 'goal-2' }],
|
||||
triggers: [{ id: 'trigger-2' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it('should set and get the program state', () => {
|
||||
useProgramStore.getState().setProgramState(mockProgram);
|
||||
|
||||
const program = useProgramStore.getState().getProgramState();
|
||||
expect(program).toEqual(mockProgram);
|
||||
});
|
||||
|
||||
it('should return the ids of all phases in the program', () => {
|
||||
useProgramStore.getState().setProgramState(mockProgram);
|
||||
|
||||
const phaseIds = useProgramStore.getState().getPhaseIds();
|
||||
expect(phaseIds).toEqual(['phase-1', 'phase-2']);
|
||||
});
|
||||
|
||||
it('should return all norms for a given phase', () => {
|
||||
useProgramStore.getState().setProgramState(mockProgram);
|
||||
|
||||
const norms = useProgramStore.getState().getNormsInPhase('phase-1');
|
||||
expect(norms).toEqual([{ id: 'norm-1' }]);
|
||||
});
|
||||
|
||||
it('should return all goals for a given phase', () => {
|
||||
useProgramStore.getState().setProgramState(mockProgram);
|
||||
|
||||
const goals = useProgramStore.getState().getGoalsInPhase('phase-2');
|
||||
expect(goals).toEqual([{ id: 'goal-2' }]);
|
||||
});
|
||||
|
||||
it('should return all triggers for a given phase', () => {
|
||||
useProgramStore.getState().setProgramState(mockProgram);
|
||||
|
||||
const triggers = useProgramStore.getState().getTriggersInPhase('phase-1');
|
||||
expect(triggers).toEqual([{ id: 'trigger-1' }]);
|
||||
});
|
||||
|
||||
it('throws if phase does not exist when getting norms', () => {
|
||||
useProgramStore.getState().setProgramState(mockProgram);
|
||||
|
||||
expect(() =>
|
||||
useProgramStore.getState().getNormsInPhase('missing-phase')
|
||||
).toThrow('phase with id:"missing-phase" not found');
|
||||
});
|
||||
|
||||
it('throws if phase does not exist when getting goals', () => {
|
||||
useProgramStore.getState().setProgramState(mockProgram);
|
||||
|
||||
expect(() =>
|
||||
useProgramStore.getState().getGoalsInPhase('missing-phase')
|
||||
).toThrow('phase with id:"missing-phase" not found');
|
||||
});
|
||||
|
||||
it('throws if phase does not exist when getting triggers', () => {
|
||||
useProgramStore.getState().setProgramState(mockProgram);
|
||||
|
||||
expect(() =>
|
||||
useProgramStore.getState().getTriggersInPhase('missing-phase')
|
||||
).toThrow('phase with id:"missing-phase" not found');
|
||||
});
|
||||
|
||||
// this test should be at the bottom to avoid conflicts with the previous tests
|
||||
it('should clone program state when setting it (no shared references should exist)', () => {
|
||||
useProgramStore.getState().setProgramState(mockProgram);
|
||||
|
||||
const storedProgram = useProgramStore.getState().getProgramState();
|
||||
|
||||
// mutate original
|
||||
(mockProgram.phases[0].norms as any[]).push({ id: 'norm-mutated' });
|
||||
|
||||
// store should NOT change
|
||||
expect(storedProgram.phases[0]['norms']).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user