import type {Edge} from "@xyflow/react"; import graphReducer, { defaultGraphPreprocessor, defaultPhaseReducer, orderPhases } from "../../../../src/pages/VisProgPage/visualProgrammingUI/GraphReducer.ts"; import type {PreparedPhase} from "../../../../src/pages/VisProgPage/visualProgrammingUI/GraphReducerTypes.ts"; import useFlowStore from "../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx"; import type {AppNode} from "../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx"; // sets of default values for nodes and edges to be used for test cases type FlowState = { name: string; nodes: AppNode[]; edges: Edge[]; }; // predefined graphs for testing: const onlyOnePhase : FlowState = { name: "onlyOnePhase", nodes: [ { id: 'start', type: 'start', position: {x: 0, y: 0}, data: {label: 'start'} }, { id: 'phase-1', type: 'phase', position: {x: 0, y: 150}, data: {label: 'Generic Phase', number: 1}, }, { id: 'end', type: 'end', position: {x: 0, y: 300}, data: {label: 'End'} } ], edges:[ { id: 'start-phase-1', source: 'start', target: 'phase-1', }, { id: 'phase-1-end', source: 'phase-1', target: 'end', } ] }; const onlyThreePhases : FlowState = { name: "onlyThreePhases", nodes: [ { id: 'start', type: 'start', position: {x: 0, y: 0}, data: {label: 'start'} }, { id: 'phase-1', type: 'phase', position: {x: 0, y: 150}, data: {label: 'Generic Phase', number: 1}, }, { id: 'phase-3', type: 'phase', position: {x: 0, y: 150}, data: {label: 'Generic Phase', number: 3}, }, { id: 'phase-2', type: 'phase', position: {x: 0, y: 150}, data: {label: 'Generic Phase', number: 2}, }, { id: 'end', type: 'end', position: {x: 0, y: 300}, data: {label: 'End'} } ], edges:[ { id: 'start-phase-1', source: 'start', target: 'phase-1', }, { id: 'phase-1-phase-2', source: 'phase-1', target: 'phase-2', }, { id: 'phase-2-phase-3', source: 'phase-2', target: 'phase-3', }, { id: 'phase-3-end', source: 'phase-3', target: 'end', } ] }; const onlySingleEdgeNorms : FlowState = { name: "onlySingleEdgeNorms", nodes: [ { id: 'start', type: 'start', position: {x: 0, y: 0}, data: {label: 'start'} }, { id: 'phase-1', type: 'phase', position: {x: 0, y: 150}, data: {label: 'Generic Phase', number: 1}, }, { id: 'norm-1', type: 'norm', position: {x: 0, y: 150}, data: {label: 'Generic Norm', value: "generic"}, }, { id: 'norm-2', type: 'norm', position: {x: 0, y: 150}, data: {label: 'Generic Norm', value: "generic"}, }, { id: 'phase-3', type: 'phase', position: {x: 0, y: 150}, data: {label: 'Generic Phase', number: 3}, }, { id: 'phase-2', type: 'phase', position: {x: 0, y: 150}, data: {label: 'Generic Phase', number: 2}, }, { id: 'end', type: 'end', position: {x: 0, y: 300}, data: {label: 'End'} } ], edges:[ { id: 'start-phase-1', source: 'start', target: 'phase-1', }, { id: 'norm-1-phase-2', source: 'norm-1', target: 'phase-2', }, { id: 'phase-1-phase-2', source: 'phase-1', target: 'phase-2', }, { id: 'phase-2-phase-3', source: 'phase-2', target: 'phase-3', }, { id: 'norm-2-phase-3', source: 'norm-2', target: 'phase-3', }, { id: 'phase-3-end', source: 'phase-3', target: 'end', } ] }; const multiEdgeNorms : FlowState = { name: "multiEdgeNorms", nodes: [ { id: 'start', type: 'start', position: {x: 0, y: 0}, data: {label: 'start'} }, { id: 'phase-1', type: 'phase', position: {x: 0, y: 150}, data: {label: 'Generic Phase', number: 1}, }, { id: 'norm-1', type: 'norm', position: {x: 0, y: 150}, data: {label: 'Generic Norm', value: "generic"}, }, { id: 'norm-2', type: 'norm', position: {x: 0, y: 150}, data: {label: 'Generic Norm', value: "generic"}, }, { id: 'phase-3', type: 'phase', position: {x: 0, y: 150}, data: {label: 'Generic Phase', number: 3}, }, { id: 'norm-3', type: 'norm', position: {x: 0, y: 150}, data: {label: 'Generic Norm', value: "generic"}, }, { id: 'phase-2', type: 'phase', position: {x: 0, y: 150}, data: {label: 'Generic Phase', number: 2}, }, { id: 'end', type: 'end', position: {x: 0, y: 300}, data: {label: 'End'} } ], edges:[ { id: 'start-phase-1', source: 'start', target: 'phase-1', }, { id: 'norm-1-phase-2', source: 'norm-1', target: 'phase-2', }, { id: 'norm-1-phase-3', source: 'norm-1', target: 'phase-3', }, { id: 'phase-1-phase-2', source: 'phase-1', target: 'phase-2', }, { id: 'norm-3-phase-1', source: 'norm-3', target: 'phase-1', }, { id: 'phase-2-phase-3', source: 'phase-2', target: 'phase-3', }, { id: 'norm-2-phase-3', source: 'norm-2', target: 'phase-3', }, { id: 'norm-2-phase-2', source: 'norm-2', target: 'phase-2', }, { id: 'phase-3-end', source: 'phase-3', target: 'end', } ] }; const onlyStartEnd : FlowState = { name: "onlyStartEnd", nodes: [ { id: 'start', type: 'start', position: {x: 0, y: 0}, data: {label: 'start'} }, { id: 'end', type: 'end', position: {x: 0, y: 300}, data: {label: 'End'} } ], edges:[ { id: 'start-end', source: 'start', target: 'end', }, ] }; // states that contain invalid programs for testing if correct errors are thrown: const phaseConnectsToInvalidNodeType : FlowState = { name: "phaseConnectsToInvalidNodeType", nodes: [ { id: 'start', type: 'start', position: {x: 0, y: 0}, data: {label: 'start'} }, { id: 'phase-1', type: 'phase', position: {x: 0, y: 150}, data: {label: 'Generic Phase', number: 1}, }, { id: 'default-1', type: 'default', position: {x: 0, y: 150}, data: {label: 'Generic Norm'}, }, { id: 'end', type: 'end', position: {x: 0, y: 300}, data: {label: 'End'} } ], edges:[ { id: 'start-phase-1', source: 'start', target: 'phase-1', }, { id: 'phase-1-default-1', source: 'phase-1', target: 'default-1', }, ] }; const phaseHasNoOutgoingConnections : FlowState = { name: "phaseHasNoOutgoingConnections", nodes: [ { id: 'start', type: 'start', position: {x: 0, y: 0}, data: {label: 'start'} }, { id: 'phase-1', type: 'phase', position: {x: 0, y: 150}, data: {label: 'Generic Phase', number: 1}, }, { id: 'phase-2', type: 'phase', position: {x: 0, y: 150}, data: {label: 'Generic Phase', number: 2}, }, { id: 'end', type: 'end', position: {x: 0, y: 300}, data: {label: 'End'} } ], edges:[ { id: 'start-phase-1', source: 'start', target: 'phase-1', }, ] }; const phaseHasTooManyOutgoingConnections : FlowState = { name: "phaseHasTooManyOutgoingConnections", nodes: [ { id: 'start', type: 'start', position: {x: 0, y: 0}, data: {label: 'start'} }, { id: 'phase-1', type: 'phase', position: {x: 0, y: 150}, data: {label: 'Generic Phase', number: 1}, }, { id: 'phase-2', type: 'phase', position: {x: 0, y: 150}, data: {label: 'Generic Phase', number: 2}, }, { id: 'end', type: 'end', position: {x: 0, y: 300}, data: {label: 'End'} } ], edges:[ { id: 'start-phase-1', source: 'start', target: 'phase-1', }, { id: 'phase-1-phase-2', source: 'phase-1', target: 'phase-2', }, { id: 'phase-1-end', source: 'phase-1', target: 'end', }, { id: 'phase-2-end', source: 'phase-2', target: 'end', }, ] }; describe('Graph Reducer Tests', () => { describe('defaultGraphPreprocessor', () => { test.each([ { state: onlyOnePhase, expected: [ { phaseNode: { id: 'phase-1', type: 'phase', position: {x: 0, y: 150}, data: {label: 'Generic Phase', number: 1}, }, nextPhaseId: 'end', connectedNorms: [], connectedGoals: [], connectedTriggers: [], }] }, { state: onlyThreePhases, expected: [ { phaseNode: { id: 'phase-1', type: 'phase', position: {x: 0, y: 150}, data: {label: 'Generic Phase', number: 1}, }, nextPhaseId: 'phase-2', connectedNorms: [], connectedGoals: [], connectedTriggers: [], }, { phaseNode: { id: 'phase-2', type: 'phase', position: {x: 0, y: 150}, data: {label: 'Generic Phase', number: 2}, }, nextPhaseId: 'phase-3', connectedNorms: [], connectedGoals: [], connectedTriggers: [], }, { phaseNode: { id: 'phase-3', type: 'phase', position: {x: 0, y: 150}, data: {label: 'Generic Phase', number: 3}, }, nextPhaseId: 'end', connectedNorms: [], connectedGoals: [], connectedTriggers: [], }] }, { state: onlySingleEdgeNorms, expected: [ { phaseNode: { id: 'phase-1', type: 'phase', position: {x: 0, y: 150}, data: {label: 'Generic Phase', number: 1}, }, nextPhaseId: 'phase-2', connectedNorms: [], connectedGoals: [], connectedTriggers: [], }, { phaseNode: { id: 'phase-2', type: 'phase', position: {x: 0, y: 150}, data: {label: 'Generic Phase', number: 2}, }, nextPhaseId: 'phase-3', connectedNorms: [{ id: 'norm-1', type: 'norm', position: {x: 0, y: 150}, data: {label: 'Generic Norm', value: "generic"}, }], connectedGoals: [], connectedTriggers: [], }, { phaseNode: { id: 'phase-3', type: 'phase', position: {x: 0, y: 150}, data: {label: 'Generic Phase', number: 3}, }, nextPhaseId: 'end', connectedNorms: [{ id: 'norm-2', type: 'norm', position: {x: 0, y: 150}, data: {label: 'Generic Norm', value: "generic"}, }], connectedGoals: [], connectedTriggers: [], }] }, { state: multiEdgeNorms, expected: [ { phaseNode: { id: 'phase-1', type: 'phase', position: {x: 0, y: 150}, data: {label: 'Generic Phase', number: 1}, }, nextPhaseId: 'phase-2', connectedNorms: [{ id: 'norm-3', type: 'norm', position: {x: 0, y: 150}, data: {label: 'Generic Norm', value: "generic"}, }], connectedGoals: [], connectedTriggers: [], }, { phaseNode: { id: 'phase-2', type: 'phase', position: {x: 0, y: 150}, data: {label: 'Generic Phase', number: 2}, }, nextPhaseId: 'phase-3', connectedNorms: [{ id: 'norm-1', type: 'norm', position: {x: 0, y: 150}, data: {label: 'Generic Norm', value: "generic"}, }, { id: 'norm-2', type: 'norm', position: {x: 0, y: 150}, data: {label: 'Generic Norm', value: "generic"}, }], connectedGoals: [], connectedTriggers: [], }, { phaseNode: { id: 'phase-3', type: 'phase', position: {x: 0, y: 150}, data: {label: 'Generic Phase', number: 3}, }, nextPhaseId: 'end', connectedNorms: [{ id: 'norm-1', type: 'norm', position: {x: 0, y: 150}, data: {label: 'Generic Norm', value: "generic"}, }, { id: 'norm-2', type: 'norm', position: {x: 0, y: 150}, data: {label: 'Generic Norm', value: "generic"}, }], connectedGoals: [], connectedTriggers: [], }] }, { state: onlyStartEnd, expected: [], } ])(`tests state: $state.name`, ({state, expected}) => { const output = defaultGraphPreprocessor(state.nodes, state.edges); expect(output).toEqual(expected); }); }); describe("orderPhases", () => { test.each([ { state: onlyOnePhase, expected: { phaseNodes: [{ id: 'phase-1', type: 'phase', position: {x: 0, y: 150}, data: {label: 'Generic Phase', number: 1}, }], connections: new Map([["phase-1","end"]]) } }, { state: onlyThreePhases, expected: { phaseNodes: [ { id: 'phase-1', type: 'phase', position: {x: 0, y: 150}, data: {label: 'Generic Phase', number: 1}, }, { id: 'phase-2', type: 'phase', position: {x: 0, y: 150}, data: {label: 'Generic Phase', number: 2}, }, { id: 'phase-3', type: 'phase', position: {x: 0, y: 150}, data: {label: 'Generic Phase', number: 3}, }], connections: new Map([ ["phase-1","phase-2"], ["phase-2","phase-3"], ["phase-3","end"] ]) } }, { state: onlySingleEdgeNorms, expected: { phaseNodes: [ { id: 'phase-1', type: 'phase', position: {x: 0, y: 150}, data: {label: 'Generic Phase', number: 1}, }, { id: 'phase-2', type: 'phase', position: {x: 0, y: 150}, data: {label: 'Generic Phase', number: 2}, }, { id: 'phase-3', type: 'phase', position: {x: 0, y: 150}, data: {label: 'Generic Phase', number: 3}, }], connections: new Map([ ["phase-1","phase-2"], ["phase-2","phase-3"], ["phase-3","end"] ]) } }, { state: onlyStartEnd, expected: { phaseNodes: [], connections: new Map() } } ])(`tests state: $state.name`, ({state, expected}) => { const output = orderPhases(state.nodes, state.edges); expect(output.phaseNodes).toEqual(expected.phaseNodes); expect(output.connections).toEqual(expected.connections); }); test.each([ { state: phaseConnectsToInvalidNodeType, expected: new Error('| INVALID PROGRAM | the node "default-1" that "phase-1" connects to is not a phase or end node') }, { state: phaseHasNoOutgoingConnections, expected: new Error('| INVALID PROGRAM | the source handle of "phase-1" doesn\'t have any outgoing connections') }, { state: phaseHasTooManyOutgoingConnections, expected: new Error('| INVALID PROGRAM | the source handle of "phase-1" connects to too many targets') } ])(`tests erroneous state: $state.name`, ({state, expected}) => { const testForError = () => { orderPhases(state.nodes, state.edges); }; expect(testForError).toThrow(expected); }) }) describe("defaultPhaseReducer", () => { test("phaseReducer handles empty norms and goals without failing", () => { const input : PreparedPhase = { phaseNode: { id: 'phase-1', type: 'phase', position: {x: 0, y: 150}, data: {label: 'Generic Phase', number: 1}, }, nextPhaseId: 'end', connectedNorms: [], connectedGoals: [], connectedTriggers: [], } const output = defaultPhaseReducer(input); expect(output).toEqual({ id: 'phase-1', name: 'Generic Phase', nextPhaseId: 'end', phaseData: { norms: [], goals: [], triggers: [], } }); }); test("defaultNormReducer reduces norms correctly", () => { const input : PreparedPhase = { phaseNode: { id: 'phase-1', type: 'phase', position: {x: 0, y: 150}, data: {label: 'Generic Phase', number: 1}, }, nextPhaseId: 'end', connectedNorms: [{ id: 'norm-1', type: 'norm', position: {x: 0, y: 150}, data: {label: 'Generic Norm', value: "generic"}, }], connectedGoals: [], connectedTriggers: [], } const output = defaultPhaseReducer(input); expect(output).toEqual({ id: 'phase-1', name: 'Generic Phase', nextPhaseId: 'end', phaseData: { norms: [{ id: 'norm-1', name: 'Generic Norm', value: "generic" }], goals: [], triggers: [], } }); }); test("defaultGoalReducer reduces goals correctly", () => { const input : PreparedPhase = { phaseNode: { id: 'phase-1', type: 'phase', position: {x: 0, y: 150}, data: {label: 'Generic Phase', number: 1}, }, nextPhaseId: 'end', connectedNorms: [], connectedGoals: [{ id: 'goal-1', type: 'goal', position: {x: 0, y: 150}, data: {label: 'Generic Goal', description: "generic", achieved: false}, }], connectedTriggers: [], } const output = defaultPhaseReducer(input); expect(output).toEqual({ id: 'phase-1', name: 'Generic Phase', nextPhaseId: 'end', phaseData: { norms: [], goals: [{ id: 'goal-1', name: 'Generic Goal', description: "generic", achieved: false, }], triggers: [], } }); }); test("defaultTriggerReducer reduces triggers correctly", () => { const input : PreparedPhase = { phaseNode: { id: 'phase-1', type: 'phase', position: {x: 0, y: 150}, data: {label: 'Generic Phase', number: 1}, }, nextPhaseId: 'end', connectedNorms: [], connectedGoals: [], connectedTriggers: [{ id: 'trigger-1', type: 'trigger', position: {x: 0, y: 150}, data: {label: 'Keyword Trigger', type: "keywords", value: [ {id: "some_id", keyword: "generic"}, {id: "another_id", keyword: "another"}, ]}, }], } const output = defaultPhaseReducer(input); expect(output).toEqual({ id: 'phase-1', name: 'Generic Phase', nextPhaseId: 'end', phaseData: { norms: [], goals: [], triggers: [{ id: 'trigger-1', label: 'Keyword Trigger', type: "keywords", value: [ {id: "some_id", keyword: "generic"}, {id: "another_id", keyword: "another"}, ] }] } }); }); }) describe("GraphReducer", () => { test.each([ { state: onlyOnePhase, expected: [ { id: 'phase-1', name: 'Generic Phase', nextPhaseId: 'end', phaseData: { norms: [], goals: [], triggers: [], } }] }, { state: onlyThreePhases, expected: [ { id: 'phase-1', name: 'Generic Phase', nextPhaseId: 'phase-2', phaseData: { norms: [], goals: [], triggers: [], } }, { id: 'phase-2', name: 'Generic Phase', nextPhaseId: 'phase-3', phaseData: { norms: [], goals: [], triggers: [], } }, { id: 'phase-3', name: 'Generic Phase', nextPhaseId: 'end', phaseData: { norms: [], goals: [], triggers: [], } }] }, { state: onlySingleEdgeNorms, expected: [ { id: 'phase-1', name: 'Generic Phase', nextPhaseId: 'phase-2', phaseData: { norms: [], goals: [], triggers: [], } }, { id: 'phase-2', name: 'Generic Phase', nextPhaseId: 'phase-3', phaseData: { norms: [ { id: 'norm-1', name: 'Generic Norm', value: "generic" } ], goals: [], triggers: [], } }, { id: 'phase-3', name: 'Generic Phase', nextPhaseId: 'end', phaseData: { norms: [{ id: 'norm-2', name: 'Generic Norm', value: "generic" }], goals: [], triggers: [], } }] }, { state: multiEdgeNorms, expected: [ { id: 'phase-1', name: 'Generic Phase', nextPhaseId: 'phase-2', phaseData: { norms: [{ id: 'norm-3', name: 'Generic Norm', value: "generic" }], goals: [], triggers: [], } }, { id: 'phase-2', name: 'Generic Phase', nextPhaseId: 'phase-3', phaseData: { norms: [ { id: 'norm-1', name: 'Generic Norm', value: "generic" }, { id: 'norm-2', name: 'Generic Norm', value: "generic" } ], goals: [], triggers: [], } }, { id: 'phase-3', name: 'Generic Phase', nextPhaseId: 'end', phaseData: { norms: [{ id: 'norm-1', name: 'Generic Norm', value: "generic" }, { id: 'norm-2', name: 'Generic Norm', value: "generic" }], goals: [], triggers: [], } }] }, { state: onlyStartEnd, expected: [], } ])(`tests state: $state.name`, ({state, expected}) => { useFlowStore.setState({nodes: state.nodes, edges: state.edges}); const output = graphReducer(); // uses default reducers expect(output).toEqual(expected); }) // we run the test for correct error handling for the entire graph reducer as well, // to make sure no errors occur before we intend to handle the errors ourselves test.each([ { state: phaseConnectsToInvalidNodeType, expected: new Error('| INVALID PROGRAM | the node "default-1" that "phase-1" connects to is not a phase or end node') }, { state: phaseHasNoOutgoingConnections, expected: new Error('| INVALID PROGRAM | the source handle of "phase-1" doesn\'t have any outgoing connections') }, { state: phaseHasTooManyOutgoingConnections, expected: new Error('| INVALID PROGRAM | the source handle of "phase-1" connects to too many targets') } ])(`tests erroneous state: $state.name`, ({state, expected}) => { useFlowStore.setState({nodes: state.nodes, edges: state.edges}); const testForError = () => { graphReducer(); }; expect(testForError).toThrow(expected); }) }) });