Merge branch 'dev' of ssh://git.science.uu.nl/ics/sp/2025/n25b/pepperplus-ui into fix/deep-clone-data

This commit is contained in:
JobvAlewijk
2025-12-07 17:07:00 +01:00
28 changed files with 858 additions and 180 deletions

View File

@@ -0,0 +1,239 @@
import {act} from '@testing-library/react';
import useFlowStore from '../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx';
import { mockReactFlow } from '../../../setupFlowTests.ts';
beforeAll(() => {
mockReactFlow();
});
describe("UndoRedo Middleware", () => {
beforeEach(() => {
jest.useFakeTimers();
});
test("pushSnapshot adds a snapshot to past and clears future", () => {
const store = useFlowStore;
store.setState({
nodes: [{
id: 'A',
type: 'default',
position: {x: 0, y: 0},
data: {label: 'A'}
}],
edges: [],
past: [],
future: [{
nodes: [
{
id: 'A',
type: 'default',
position: {x: 0, y: 0},
data: {label: 'A'}
},
],
edges: []
}],
});
act(() => {
store.getState().pushSnapshot();
})
const state = store.getState();
expect(state.past.length).toBe(1);
expect(state.past[0]).toEqual({
nodes: [{
id: 'A',
type: 'default',
position: {x: 0, y: 0},
data: {label: 'A'}
}],
edges: []
});
expect(state.future).toEqual([]);
});
test("pushSnapshot does nothing during batch action", () => {
const store = useFlowStore;
act(() => {
store.setState({ isBatchAction: true });
store.getState().pushSnapshot();
})
expect(store.getState().past.length).toBe(0);
});
test("undo restores last snapshot and pushes current snapshot to future", () => {
const store = useFlowStore;
// initial state
store.setState({
nodes: [{
id: 'A',
type: 'default',
position: {x: 0, y: 0},
data: {label: 'A'}
}],
edges: []
});
act(() => {
store.getState().pushSnapshot();
// modified state
store.setState({
nodes: [{
id: 'B',
type: 'default',
position: {x: 0, y: 0},
data: {label: 'B'}
}],
edges: []
});
store.getState().undo();
})
expect(store.getState().nodes).toEqual([{
id: 'A',
type: 'default',
position: {x: 0, y: 0},
data: {label: 'A'}
}]);
expect(store.getState().future.length).toBe(1);
expect(store.getState().future[0]).toEqual({
nodes: [{
id: 'B',
type: 'default',
position: {x: 0, y: 0},
data: {label: 'B'}
}],
edges: []
});
});
test("undo does nothing when past is empty", () => {
const store = useFlowStore;
store.setState({past: []});
act(() => { store.getState().undo(); });
expect(store.getState().nodes).toEqual([]);
expect(store.getState().future).toEqual([]);
});
test("redo restores last future snapshot and pushes current to past", () => {
const store = useFlowStore;
// initial
store.setState({
nodes: [{
id: 'A',
type: 'default',
position: {x: 0, y: 0},
data: {label: 'A'}
}],
edges: []
});
act(() => {
store.getState().pushSnapshot();
store.setState({
nodes: [{
id: 'B',
type: 'default',
position: {x: 0, y: 0},
data: {label: 'B'}
}],
edges: []
});
store.getState().undo();
// redo should restore node with id 'B'
store.getState().redo();
})
expect(store.getState().nodes).toEqual([{
id: 'B',
type: 'default',
position: {x: 0, y: 0},
data: {label: 'B'}
}]);
expect(store.getState().past.length).toBe(1); // snapshot A stored
expect(store.getState().past[0]).toEqual({
nodes: [{
id: 'A',
type: 'default',
position: {x: 0, y: 0},
data: {label: 'A'}
}],
edges: []
});
});
test("redo does nothing when future is empty", () => {
const store = useFlowStore;
store.setState({past: []});
act(() => { store.getState().redo(); });
expect(store.getState().nodes).toEqual([]);
});
test("beginBatchAction pushes snapshot and sets isBatchAction=true", () => {
const store = useFlowStore;
store.setState({
nodes: [{
id: 'A',
type: 'default',
position: {x: 0, y: 0},
data: {label: 'A'}
}],
edges: []
});
act(() => { store.getState().beginBatchAction(); });
expect(store.getState().isBatchAction).toBe(true);
expect(store.getState().past.length).toBe(1);
});
test("endBatchAction sets isBatchAction=false after timeout", () => {
const store = useFlowStore;
store.setState({ isBatchAction: true });
act(() => { store.getState().endBatchAction(); });
// isBatchAction should remain true before the timer has advanced
expect(store.getState().isBatchAction).toBe(true);
jest.advanceTimersByTime(10);
// it should now be set to false as the timer has advanced enough
expect(store.getState().isBatchAction).toBe(false);
});
test("multiple beginBatchAction calls clear the timeout", () => {
const store = useFlowStore;
act(() => {
store.getState().beginBatchAction();
store.getState().endBatchAction(); // starts timeout
store.getState().beginBatchAction(); // should clear previous timeout
});
jest.advanceTimersByTime(10);
// After advancing the timers, isBatchAction should still be true,
// as the timeout should have been cleared
expect(store.getState().isBatchAction).toBe(true);
});
});

View File

@@ -1,7 +1,7 @@
import { getByTestId, render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores';
import VisProgPage from '../../../../../src/pages/VisProgPage/VisProg';
import userEvent from '@testing-library/user-event';
@@ -52,13 +52,7 @@ jest.mock('@xyflow/react', () => {
};
});
// Reset Zustand state helper
function resetStore() {
useFlowStore.setState({ nodes: [], edges: [] });
}
describe("Drag & drop node creation", () => {
beforeEach(() => resetStore());
test("drops a phase node inside the canvas and adds it with transformed position", async () => {
const user = userEvent.setup();

View File

@@ -1,7 +1,7 @@
import { describe, it, beforeEach } from '@jest/globals';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders, resetFlowStore } from '../.././/./../../test-utils/test-utils';
import { renderWithProviders } from '../.././/./../../test-utils/test-utils';
import NormNode, { NormReduce, NormConnects, type NormNodeData } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode'
import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores';
import type { Node } from '@xyflow/react';
@@ -13,7 +13,6 @@ describe('NormNode', () => {
let user: ReturnType<typeof userEvent.setup>;
beforeEach(() => {
resetFlowStore();
user = userEvent.setup();
});

View File

@@ -1,15 +1,13 @@
import { describe, it, beforeEach } from '@jest/globals';
import { screen } from '@testing-library/react';
import { renderWithProviders, resetFlowStore } from '../.././/./../../test-utils/test-utils';
import StartNode, { StartReduce, StartConnects } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode';
import type { Node } from '@xyflow/react';
import { describe, it } from '@jest/globals';
import '@testing-library/jest-dom';
import { screen } from '@testing-library/react';
import type { Node } from '@xyflow/react';
import { renderWithProviders } from '../.././/./../../test-utils/test-utils';
import StartNode, { StartReduce, StartConnects } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode';
describe('StartNode', () => {
beforeEach(() => {
resetFlowStore();
});
describe('Rendering', () => {
it('renders the StartNode correctly', () => {

View File

@@ -1,7 +1,7 @@
import { describe, it, beforeEach } from '@jest/globals';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders, resetFlowStore } from '../.././/./../../test-utils/test-utils';
import { renderWithProviders } from '../.././/./../../test-utils/test-utils';
import TriggerNode, { TriggerReduce, TriggerConnects, TriggerNodeCanConnect, type TriggerNodeData } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode';
import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores';
import type { Node } from '@xyflow/react';
@@ -11,7 +11,6 @@ describe('TriggerNode', () => {
let user: ReturnType<typeof userEvent.setup>;
beforeEach(() => {
resetFlowStore();
user = userEvent.setup();
});
@@ -193,22 +192,19 @@ describe('TriggerNode', () => {
},
};
const allNodes: Node[] = [triggerNode];
const result = TriggerReduce(triggerNode, allNodes);
const allNodes: Node[] = [triggerNode];
const result = TriggerReduce(triggerNode, allNodes);
expect(result).toEqual({
id: "trigger-1",
type: "keywords",
label: 'Keyword Trigger',
keywords: [
{
"id": "kw1",
"keyword": "hello",
},],
});
expect(result).toEqual({
id: 'trigger-1',
type: 'keywords',
label: 'Keyword Trigger',
keywords: [{ id: 'kw1', keyword: 'hello' }],
});
});
});
describe('TriggerConnects Function', () => {
it('should handle connection without errors', () => {
const node1: Node = {

View File

@@ -1,6 +1,6 @@
import { describe, beforeEach } from '@jest/globals';
import { screen } from '@testing-library/react';
import { renderWithProviders, resetFlowStore } from '../.././/./../../test-utils/test-utils';
import { renderWithProviders } from '../.././/./../../test-utils/test-utils';
import type { XYPosition } from '@xyflow/react';
import { NodeTypes, NodeDefaults, NodeConnects, NodeReduces, NodesInPhase } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/NodeRegistry';
import '@testing-library/jest-dom'
@@ -10,12 +10,11 @@ import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgramming
describe('NormNode', () => {
beforeEach(() => {
resetFlowStore();
jest.clearAllMocks();
});
function createNode(id: string, type: string, position: XYPosition, data: Record<string, unknown>, deletable?: boolean) {
const defaultData = JSON.parse(JSON.stringify(NodeDefaults[type as keyof typeof NodeDefaults]))
const defaultData = NodeDefaults[type as keyof typeof NodeDefaults]
const newData = {
id: id,
type: type,
@@ -46,30 +45,35 @@ describe('NormNode', () => {
describe('Rendering', () => {
test.each(getAllTypes())('it should render %s node with the default data', (nodeType) => {
const lengthBefore = screen.getAllByText(/.*/).length;
const lengthBefore = screen.getAllByText(/.*/).length;
const newNode = createNode(nodeType + "1", nodeType, {x: 200, y:200}, {})
const uiElement = Object.entries(NodeTypes).find(([t])=>t==nodeType)?.[1];
expect(uiElement).toBeDefined();
const props = {
id:newNode.id,
type:newNode.type as string,
data:newNode.data as any,
selected:false,
isConnectable:true,
zIndex:0,
dragging:false,
selectable:true,
deletable:true,
draggable:true,
positionAbsoluteX:0,
positionAbsoluteY:0,}
const newNode = createNode(nodeType + "1", nodeType, {x: 200, y:200}, {});
renderWithProviders(createElement(uiElement as React.ComponentType<any>, props));
const lengthAfter = screen.getAllByText(/.*/).length;
expect(lengthBefore + 1 == lengthAfter)
});
const found = Object.entries(NodeTypes).find(([t]) => t === nodeType);
const uiElement = found ? found[1] : null;
expect(uiElement).not.toBeNull();
const props = {
id: newNode.id,
type: newNode.type as string,
data: newNode.data as any,
selected: false,
isConnectable: true,
zIndex: 0,
dragging: false,
selectable: true,
deletable: true,
draggable: true,
positionAbsoluteX: 0,
positionAbsoluteY: 0,
};
renderWithProviders(createElement(uiElement as React.ComponentType<any>, props));
const lengthAfter = screen.getAllByText(/.*/).length;
expect(lengthBefore + 1 === lengthAfter);
});
});

View File

@@ -69,6 +69,9 @@ beforeAll(() => {
useFlowStore.setState({
nodes: [],
edges: [],
past: [],
future: [],
isBatchAction: false,
edgeReconnectSuccessful: true
});
});
@@ -78,6 +81,9 @@ afterEach(() => {
useFlowStore.setState({
nodes: [],
edges: [],
past: [],
future: [],
isBatchAction: false,
edgeReconnectSuccessful: true
});
});

View File

@@ -2,7 +2,6 @@
import { render, type RenderOptions } from '@testing-library/react';
import { type ReactElement, type ReactNode } from 'react';
import { ReactFlowProvider } from '@xyflow/react';
import useFlowStore from '../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores';
/**
* Custom render function that wraps components with necessary providers
@@ -19,15 +18,7 @@ export function renderWithProviders(
return render(ui, { wrapper: Wrapper, ...options });
}
/**
* Helper to reset the Zustand store between tests
* This ensures test isolation
*/
export function resetFlowStore() {
useFlowStore.setState({
nodes: [],
edges: [],
edgeReconnectSuccessful: true,
});
}
// Re-export everything from testing library
//eslint-disable-next-line react-refresh/only-export-components
export * from '@testing-library/react';