fix: incorrect phase reduction order

This commit is contained in:
Gerla, J. (Justin)
2026-01-06 15:12:00 +00:00
committed by Pim Hutting
parent 9c80391fea
commit 9b3414ba98
13 changed files with 520 additions and 79 deletions

View File

@@ -3,6 +3,9 @@ import useFlowStore from '../../../../src/pages/VisProgPage/visualProgrammingUI/
import { mockReactFlow } from '../../../setupFlowTests.ts';
beforeAll(() => {
mockReactFlow();
});

View File

@@ -1,8 +1,10 @@
import type { Node, Edge, Connection } from '@xyflow/react'
import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores';
import type { PhaseNodeData } from "../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode";
import { getByTestId, render } from '@testing-library/react';
import type {PhaseNode, PhaseNodeData} from "../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode";
import {act, getByTestId, render} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import VisProgPage from '../../../../../src/pages/VisProgPage/VisProg';
import {mockReactFlow} from "../../../../setupFlowTests.ts";
class ResizeObserver {
@@ -98,4 +100,195 @@ describe('PhaseNode', () => {
expect(p1_data.children.length == 1);
expect(p2_data.children.length == 2);
});
});
});
// --| Helper functions |--
function createPhaseNode(
id: string,
overrides: Partial<PhaseNodeData> = {},
): Node<PhaseNodeData> {
return {
id: id,
type: 'phase',
position: { x: 0, y: 0 },
data: {
label: 'Phase',
droppable: true,
children: [],
hasReduce: true,
nextPhaseId: null,
isFirstPhase: false,
...overrides,
},
}
}
function createNode(id: string, type: string): Node {
return {
id: id,
type: type,
position: { x: 0, y: 0 },
data: {},
}
}
function connect(source: string, target: string): Connection {
return {
source: source,
target: target,
sourceHandle: null,
targetHandle: null
};
}
function edge(source: string, target: string): Edge {
return {
id: `${source}-${target}`,
source: source,
target: target,
}
}
// --| Connection Tests |--
describe('PhaseNode Connection logic', () => {
beforeAll(() => {
mockReactFlow();
});
describe('PhaseConnections', () => {
test('connecting start => phase sets isFirstPhase to true', () => {
const phase = createPhaseNode('phase-1')
const start = createNode('start', 'start')
useFlowStore.setState({ nodes: [phase, start] })
// verify it starts of false
expect(phase.data.isFirstPhase).toBe(false);
act(() => {
useFlowStore.getState().onConnect(connect('start', 'phase-1'))
})
const updatedPhase = useFlowStore
.getState()
.nodes.find((n) => n.id === 'phase-1') as PhaseNode
expect(updatedPhase.data.isFirstPhase).toBe(true)
})
test('connecting task => phase adds child', () => {
const phase = createPhaseNode('phase-1')
const norm = createNode('norm-1', 'norm')
useFlowStore.setState({ nodes: [phase, norm] })
act(() => {
useFlowStore.getState().onConnect(connect('norm-1', 'phase-1'))
})
const updatedPhase = useFlowStore
.getState()
.nodes.find((n) => n.id === 'phase-1') as PhaseNode
expect(updatedPhase.data.children).toEqual(['norm-1'])
})
test('connecting phase => phase sets nextPhaseId', () => {
const p1 = createPhaseNode('phase-1')
const p2 = createPhaseNode('phase-2')
useFlowStore.setState({ nodes: [p1, p2] })
act(() => {
useFlowStore.getState().onConnect(connect('phase-1', 'phase-2'))
})
const updatedP1 = useFlowStore
.getState()
.nodes.find((n) => n.id === 'phase-1') as PhaseNode
expect(updatedP1.data.nextPhaseId).toBe('phase-2')
})
test('connecting phase to end => phase sets nextPhaseId to "end"', () => {
const phase = createPhaseNode('phase-1')
const end = createNode('end', 'end')
useFlowStore.setState({ nodes: [phase, end] })
act(() => {
useFlowStore.getState().onConnect(connect('phase-1', 'end'))
})
const updatedPhase = useFlowStore
.getState()
.nodes.find((n) => n.id === 'phase-1') as PhaseNode
expect(updatedPhase.data.nextPhaseId).toBe('end')
})
})
describe('PhaseDisconnections', () => {
test('disconnecting task => phase removes child', () => {
const phase = createPhaseNode('phase-1', { children: ['norm-1'] })
const norm = createNode('norm-1', 'norm')
useFlowStore.setState({
nodes: [phase, norm],
edges: [edge('norm-1', 'phase-1')]
})
act(() => {
useFlowStore.getState().onEdgesDelete([edge('norm-1', 'phase-1')])
})
const updatedPhase = useFlowStore
.getState()
.nodes.find((n) => n.id === 'phase-1') as PhaseNode
expect(updatedPhase.data.children).toEqual([])
})
test('disconnecting start => phase sets isFirstPhase to false', () => {
const phase = createPhaseNode('phase-1', { isFirstPhase: true })
const start = createNode('start', 'start')
useFlowStore.setState({
nodes: [phase, start],
edges: [edge('start', 'phase-1')]
})
act(() => {
useFlowStore.getState().onEdgesDelete([edge('start', 'phase-1')])
})
const updatedPhase = useFlowStore
.getState()
.nodes.find((n) => n.id === 'phase-1') as PhaseNode
expect(updatedPhase.data.isFirstPhase).toBe(false)
})
test('disconnecting phase => phase sets nextPhaseId to null', () => {
const p1 = createPhaseNode('phase-1', { nextPhaseId: 'phase-2' })
const p2 = createPhaseNode('phase-2')
useFlowStore.setState({
nodes: [p1, p2],
edges: [edge('phase-1', 'phase-2')]
})
act(() => {
useFlowStore.getState().onEdgesDelete([edge('phase-1', 'phase-2')])
})
const updatedP1 = useFlowStore
.getState()
.nodes.find((n) => n.id === 'phase-1') as PhaseNode
expect(updatedP1.data.nextPhaseId).toBeNull()
})
})
})

View File

@@ -13,19 +13,17 @@ describe('NormNode', () => {
jest.clearAllMocks();
});
function createNode(id: string, type: string, position: XYPosition, data: Record<string, unknown>, deletable?: boolean) {
function createNode(id: string, type: string, position: XYPosition, data: Record<string, unknown>, deletable? : boolean) {
const defaultData = NodeDefaults[type as keyof typeof NodeDefaults]
return {
id,
type,
position,
deletable,
data: {
...defaultData,
...data,
},
id: id,
type: type,
position: position,
data: {...defaultData, ...data},
deletable: deletable
}
}
}
/**
@@ -47,34 +45,34 @@ 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 newNode = createNode(nodeType + "1", nodeType, {x: 200, y:200}, {});
const found = Object.entries(NodeTypes).find(([t]) => t === nodeType);
const uiElement = found ? found[1] : null;
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,
};
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;
renderWithProviders(createElement(uiElement as React.ComponentType<any>, props));
const lengthAfter = screen.getAllByText(/.*/).length;
expect(lengthBefore + 1 === lengthAfter);
});
expect(lengthBefore + 1 === lengthAfter);
});
});

View File

@@ -0,0 +1,110 @@
import type {PhaseNode} from "../../src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx";
import orderPhaseNodeArray from "../../src/utils/orderPhaseNodes.ts";
function createPhaseNode(
id: string,
isFirst: boolean = false,
nextPhaseId: string | null = null
): PhaseNode {
return {
id: id,
type: 'phase',
position: { x: 0, y: 0 },
data: {
label: 'Phase',
droppable: true,
children: [],
hasReduce: true,
nextPhaseId: nextPhaseId,
isFirstPhase: isFirst,
},
}
}
describe("orderPhaseNodes", () => {
test.each([
{
testCase: {
testName: "Throws correct error when there is no first phase (empty input array)",
input: [],
expected: "No phaseNode with isFirstObject = true found"
}
},{
testCase: {
testName: "Throws correct error when there is no first phase",
input: [
createPhaseNode("phase-1", false, "phase-2"),
createPhaseNode("phase-2", false, "phase-3"),
createPhaseNode("phase-3", false, "end")
],
expected: "No phaseNode with isFirstObject = true found"
}
},{
testCase: {
testName: "Throws correct error when the program doesn't lead to an end node (missing phase-phase connection)",
input: [
createPhaseNode("phase-1", true, "phase-2"),
createPhaseNode("phase-2", false, null),
createPhaseNode("phase-3", false, "end")
],
expected: "Incomplete phase sequence, program does not reach the end node"
}
},{
testCase: {
testName: "Throws correct error when the program doesn't lead to an end node (missing phase-end connection)",
input: [
createPhaseNode("phase-1", true, "phase-2"),
createPhaseNode("phase-2", false, "phase-3"),
createPhaseNode("phase-3", false, null)
],
expected: "Incomplete phase sequence, program does not reach the end node"
}
},{
testCase: {
testName: "Throws correct error when the program leads to a non-existent phase",
input: [
createPhaseNode("phase-1", true, "phase-2"),
createPhaseNode("phase-2", false, "phase-3"),
createPhaseNode("phase-3", false, "phase-4")
],
expected: "Incomplete phase sequence, phaseNode with id \"phase-4\" not found"
}
}
])(`Error Handling: $testCase.testName`, ({testCase}) => {
expect(() => { orderPhaseNodeArray(testCase.input) }).toThrow(testCase.expected);
})
test.each([
{
testCase: {
testName: "Already correctly ordered phases stay ordered",
input: [
createPhaseNode("phase-1", true, "phase-2"),
createPhaseNode("phase-2", false, "phase-3"),
createPhaseNode("phase-3", false, "end")
],
expected: [
createPhaseNode("phase-1", true, "phase-2"),
createPhaseNode("phase-2", false, "phase-3"),
createPhaseNode("phase-3", false, "end")
]
}
},{
testCase: {
testName: "Incorrectly ordered phases get ordered correctly",
input: [
createPhaseNode("phase-3", false, "end"),
createPhaseNode("phase-1", true, "phase-2"),
createPhaseNode("phase-2", false, "phase-3"),
],
expected: [
createPhaseNode("phase-1", true, "phase-2"),
createPhaseNode("phase-2", false, "phase-3"),
createPhaseNode("phase-3", false, "end")
]
}
}
])(`Functional: $testCase.testName`, ({testCase}) => {
const output = orderPhaseNodeArray(testCase.input);
expect(output).toEqual(testCase.expected);
})
})