Compare commits

...

11 Commits

Author SHA1 Message Date
JobvAlewijk
2ecb33dcde chore: now actually runs tests 2026-01-06 15:19:49 +01:00
Gerla, J. (Justin)
15c9fc6f4d Merge branch 'demo' into 'feat/simple-program-page'
# Conflicts:
#   test/utils/programStore.test.ts
2026-01-06 13:37:27 +00:00
JobvAlewijk
9dae45e398 Merge branch 'feat/make-program-data-available-on-all-pages' into 'demo'
feat: made (reduced) program data available on all pages

See merge request ics/sp/2025/n25b/pepperplus-ui!36
2026-01-06 12:27:22 +00:00
Gerla, J. (Justin)
bd93b04bfd feat: made (reduced) program data available on all pages 2026-01-06 12:27:22 +00:00
JobvAlewijk
7b05c7344c feat: added tests
ref: N25B-399
2026-01-02 21:06:41 +01:00
JobvAlewijk
d80ced547c feat: SimpleProgram no longer relies on types
ref: N25B-399
2026-01-02 20:55:24 +01:00
JobvAlewijk
cd1aa84f89 feat: using programstore
ref: N25B-399
2026-01-02 20:43:20 +01:00
JobvAlewijk
469a6c7a69 build: merge
ref: N25B-402
2026-01-02 19:56:01 +01:00
JobvAlewijk
b0a5e4770c feat: improved visuals and structure
ref: N25B-402
2025-12-30 20:56:05 +01:00
JobvAlewijk
f0fe520ea0 feat: first version of simple program shown
shows up if you run the program

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

ref: N25B-428
2025-12-20 22:58:22 +01:00
7 changed files with 766 additions and 2 deletions

View File

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

View File

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

View File

@@ -7,14 +7,16 @@ import {
MarkerType, MarkerType,
} from '@xyflow/react'; } from '@xyflow/react';
import '@xyflow/react/dist/style.css'; import '@xyflow/react/dist/style.css';
import {useEffect} from "react"; import {useEffect, useState} from "react";
import {useShallow} from 'zustand/react/shallow'; import {useShallow} from 'zustand/react/shallow';
import useProgramStore from "../../utils/programStore.ts";
import {DndToolbar} from './visualProgrammingUI/components/DragDropSidebar.tsx'; import {DndToolbar} from './visualProgrammingUI/components/DragDropSidebar.tsx';
import useFlowStore from './visualProgrammingUI/VisProgStores.tsx'; import useFlowStore from './visualProgrammingUI/VisProgStores.tsx';
import type {FlowState} from './visualProgrammingUI/VisProgTypes.tsx'; import type {FlowState} from './visualProgrammingUI/VisProgTypes.tsx';
import styles from './VisProg.module.css' import styles from './VisProg.module.css'
import { NodeReduces, NodeTypes } from './visualProgrammingUI/NodeRegistry.ts'; import { NodeReduces, NodeTypes } from './visualProgrammingUI/NodeRegistry.ts';
import SaveLoadPanel from './visualProgrammingUI/components/SaveLoadPanel.tsx'; import SaveLoadPanel from './visualProgrammingUI/components/SaveLoadPanel.tsx';
import SimpleProgram from "../SimpleProgram/SimpleProgram.tsx";
// --| config starting params for flow |-- // --| config starting params for flow |--
@@ -152,6 +154,10 @@ function runProgram() {
).then((res) => { ).then((res) => {
if (!res.ok) throw new Error("Failed communicating with the backend.") if (!res.ok) throw new Error("Failed communicating with the backend.")
console.log("Successfully sent the program to 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.")); }).catch(() => console.log("Failed to send program to the backend."));
} }
@@ -174,10 +180,31 @@ function graphReducer() {
* @constructor * @constructor
*/ */
function VisProgPage() { function VisProgPage() {
const [showSimpleProgram, setShowSimpleProgram] = useState(false);
const setProgramState = useProgramStore((state) => state.setProgramState);
const onClick = () => {
const phases = graphReducer(); // reduce graph
setProgramState({ phases }); // <-- save to store
setShowSimpleProgram(true); // show SimpleProgram
runProgram(); // send to backend if needed
};
if (showSimpleProgram) {
return (
<div>
<button onClick={() => setShowSimpleProgram(false)}>
Back to Editor
</button>
<SimpleProgram/>
</div>
);
}
return ( return (
<> <>
<VisualProgrammingUI/> <VisualProgrammingUI/>
<button onClick={runProgram}>run program</button> <button onClick={onClick}>run program</button>
</> </>
) )
} }

81
src/utils/programStore.ts Normal file
View 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;

View File

@@ -0,0 +1,176 @@
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("renders empty messages when phase has no data", () => {
loadProgram([
{
id: "phase-1",
norms: [],
goals: [],
triggers: [],
},
]);
render(<SimpleProgram />);
expect(screen.getAllByText("No norms defined.").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("No goals defined.").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("No triggers defined.").length).toBeGreaterThanOrEqual(1);
expect(
screen.getByText("No conditional norms defined.")
).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();
});
test("prev and next buttons enable/disable correctly when navigating", () => {
loadProgram([
{ id: "p1", norms: [], goals: [], triggers: [] },
{ id: "p2", norms: [], goals: [], triggers: [] },
]);
render(<SimpleProgram />);
const prev = screen.getByText("◀ Prev");
const next = screen.getByText("Next ▶");
expect(prev).toBeDisabled();
expect(next).not.toBeDisabled();
fireEvent.click(next);
expect(prev).not.toBeDisabled();
expect(next).toBeDisabled();
});
test("renders achieved and unachieved goals with correct icons", () => {
loadProgram([
{
id: "phase-1",
norms: [],
goals: [
{ id: "g1", description: "Done goal", achieved: true },
{ id: "g2", description: "Failed goal", achieved: false },
],
triggers: [],
},
]);
render(<SimpleProgram />);
expect(screen.getByText("✔")).toBeInTheDocument();
expect(screen.getByText("✖")).toBeInTheDocument();
expect(screen.getByText("Done goal")).toBeInTheDocument();
expect(screen.getByText("Failed goal")).toBeInTheDocument();
});
test("renders fallback labels when optional fields are missing", () => {
loadProgram([
{
id: "phase-1",
norms: [{}],
goals: [{}],
triggers: [{}],
},
]);
render(<SimpleProgram />);
expect(screen.getByText("Unnamed norm")).toBeInTheDocument();
expect(screen.getByText("Unnamed goal")).toBeInTheDocument();
expect(screen.getByText("Unnamed trigger")).toBeInTheDocument();
});
test("does not crash when navigating beyond boundaries", () => {
loadProgram([
{ id: "p1", norms: [], goals: [], triggers: [] },
{ id: "p2", norms: [], goals: [], triggers: [] },
]);
render(<SimpleProgram />);
fireEvent.click(screen.getByText("Next ▶"));
fireEvent.click(screen.getByText("Next ▶"));
expect(screen.getByText("Phase 2 / 2")).toBeInTheDocument();
fireEvent.click(screen.getByText("◀ Prev"));
fireEvent.click(screen.getByText("◀ Prev"));
expect(screen.getByText("Phase 1 / 2")).toBeInTheDocument();
});
});

View File

@@ -2,6 +2,11 @@ import '@testing-library/jest-dom';
import { cleanup } from '@testing-library/react'; import { cleanup } from '@testing-library/react';
import useFlowStore from '../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx'; 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 // To make sure that the tests are working, it's important that you are using
// this implementation of ResizeObserver and DOMMatrixReadOnly // this implementation of ResizeObserver and DOMMatrixReadOnly

View File

@@ -0,0 +1,116 @@
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');
});
it('should clone program state when setting it (no shared references should exist)', () => {
const changeableMockProgram: 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' }],
},
],
};
useProgramStore.getState().setProgramState(changeableMockProgram);
const storedProgram = useProgramStore.getState().getProgramState();
// mutate original
(changeableMockProgram.phases[0].norms as any[]).push({ id: 'norm-mutated' });
// store should NOT change
expect(storedProgram.phases[0]['norms']).toHaveLength(1);
});
});