feat: added rule based connection validation and connection limits to the editor

This commit is contained in:
Gerla, J. (Justin)
2026-01-07 13:32:53 +00:00
committed by Björn Otgaar
parent 6d1c17e77b
commit 9e7c192804
20 changed files with 672 additions and 89 deletions

View File

@@ -0,0 +1,86 @@
import {renderHook} from "@testing-library/react";
import type {Connection} from "@xyflow/react";
import {
ruleResult,
type RuleResult,
useHandleRules
} from "../../../../src/pages/VisProgPage/visualProgrammingUI/HandleRuleLogic.ts";
import useFlowStore from "../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx";
describe('useHandleRules', () => {
it('should register rules on mount and validate connection', () => {
const rules = [() => ({ isSatisfied: true } as RuleResult)];
const { result } = renderHook(() => useHandleRules('node1', 'h1', 'target', rules));
// Confirm rules registered
const storedRules = useFlowStore.getState().getTargetRules('node1', 'h1');
expect(storedRules).toEqual(rules);
// Validate a connection
const connection = { source: 'node2', sourceHandle: 'h2', target: 'node1', targetHandle: 'h1' };
const validation = result.current(connection);
expect(validation).toEqual(ruleResult.satisfied);
});
it('should throw error if targetHandle missing', () => {
const rules: any[] = [];
const { result } = renderHook(() => useHandleRules('node1', 'h1', 'target', rules));
expect(() =>
result.current({ source: 'a', target: 'b', targetHandle: null, sourceHandle: null })
).toThrow('No target handle was provided');
});
});
describe('useHandleRules with multiple failed rules', () => {
it('should return the first failed rule message and consider connectionCount', () => {
// Mock rules for the target handle
const failingRules = [
(_conn: any, ctx: any) => {
if (ctx.connectionCount >= 1) {
return { isSatisfied: false, message: 'Max connections reached' } as RuleResult;
}
return { isSatisfied: true } as RuleResult;
},
() => ({ isSatisfied: false, message: 'Other rule failed' } as RuleResult),
() => ({ isSatisfied: true } as RuleResult),
];
// Register rules for the target handle
useFlowStore.getState().registerRules('targetNode', 'targetHandle', failingRules);
// Add one existing edge to simulate connectionCount
useFlowStore.setState({
edges: [
{
id: 'edge-1',
source: 'sourceNode',
sourceHandle: 'sourceHandle',
target: 'targetNode',
targetHandle: 'targetHandle',
},
],
});
// Create hook for a source node handle
const rulesForSource = [
(_c: Connection) => ({ isSatisfied: true } as RuleResult)
];
const { result } = renderHook(() =>
useHandleRules('sourceNode', 'sourceHandle', 'source', rulesForSource)
);
const connection = {
source: 'sourceNode',
sourceHandle: 'sourceHandle',
target: 'targetNode',
targetHandle: 'targetHandle',
};
const validation = result.current(connection);
// Should fail with first failing rule message
expect(validation).toEqual(ruleResult.notSatisfied('Max connections reached'));
});
});

View File

@@ -0,0 +1,84 @@
import {ruleResult} from "../../../../src/pages/VisProgPage/visualProgrammingUI/HandleRuleLogic.ts";
import {
allowOnlyConnectionsFromType,
allowOnlyConnectionsFromHandle, noSelfConnections
} from "../../../../src/pages/VisProgPage/visualProgrammingUI/HandleRules.ts";
import useFlowStore from "../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx";
beforeEach(() => {
useFlowStore.setState({
nodes: [
{ id: 'nodeA', type: 'typeA', position: { x: 0, y: 0 }, data: {} },
{ id: 'nodeB', type: 'typeB', position: { x: 0, y: 0 }, data: {} },
],
});
});
describe('allowOnlyConnectionsFromType', () => {
it('should allow connection from allowed node type', () => {
const rule = allowOnlyConnectionsFromType(['typeA']);
const connection = { source: 'nodeA', sourceHandle: 'h1', target: 'nodeB', targetHandle: 'h2' };
const context = { connectionCount: 0, source: { id: 'nodeA', handleId: 'h1' }, target: { id: 'nodeB', handleId: 'h2' } };
const result = rule(connection, context);
expect(result).toEqual(ruleResult.satisfied);
});
it('should not allow connection from disallowed node type', () => {
const rule = allowOnlyConnectionsFromType(['typeA']);
const connection = { source: 'nodeB', sourceHandle: 'h1', target: 'nodeA', targetHandle: 'h2' };
const context = { connectionCount: 0, source: { id: 'nodeB', handleId: 'h1' }, target: { id: 'nodeA', handleId: 'h2' } };
const result = rule(connection, context);
expect(result).toEqual(ruleResult.notSatisfied("the target doesn't allow connections from nodes with type: typeB"));
});
});
describe('allowOnlyConnectionsFromHandle', () => {
it('should allow connection from node with correct type and handle', () => {
const rule = allowOnlyConnectionsFromHandle([{ nodeType: 'typeA', handleId: 'h1' }]);
const connection = { source: 'nodeA', sourceHandle: 'h1', target: 'nodeB', targetHandle: 'h2' };
const context = { connectionCount: 0, source: { id: 'nodeA', handleId: 'h1' }, target: { id: 'nodeB', handleId: 'h2' } };
const result = rule(connection, context);
expect(result).toEqual(ruleResult.satisfied);
});
it('should not allow connection from node with wrong handle', () => {
const rule = allowOnlyConnectionsFromHandle([{ nodeType: 'typeA', handleId: 'h1' }]);
const connection = { source: 'nodeA', sourceHandle: 'wrongHandle', target: 'nodeB', targetHandle: 'h2' };
const context = { connectionCount: 0, source: { id: 'nodeA', handleId: 'wrongHandle' }, target: { id: 'nodeB', handleId: 'h2' } };
const result = rule(connection, context);
expect(result).toEqual(ruleResult.notSatisfied("the target doesn't allow connections from nodes with type: typeA"));
});
it('should not allow connection from node with wrong type', () => {
const rule = allowOnlyConnectionsFromHandle([{ nodeType: 'typeA', handleId: 'h1' }]);
const connection = { source: 'nodeB', sourceHandle: 'h1', target: 'nodeA', targetHandle: 'h2' };
const context = { connectionCount: 0, source: { id: 'nodeB', handleId: 'h1' }, target: { id: 'nodeA', handleId: 'h2' } };
const result = rule(connection, context);
expect(result).toEqual(ruleResult.notSatisfied("the target doesn't allow connections from nodes with type: typeB"));
});
});
describe('noSelfConnections', () => {
it('should allow connection from node with other type and handle', () => {
const rule = noSelfConnections;
const connection = { source: 'nodeA', sourceHandle: 'h1', target: 'nodeB', targetHandle: 'h2' };
const context = { connectionCount: 0, source: { id: 'nodeA', handleId: 'h1' }, target: { id: 'nodeB', handleId: 'h2' } };
const result = rule(connection, context);
expect(result).toEqual(ruleResult.satisfied);
});
it('should not allow connection from other handle on same node', () => {
const rule = noSelfConnections;
const connection = { source: 'nodeA', sourceHandle: 'h1', target: 'nodeA', targetHandle: 'h2' };
const context = { connectionCount: 0, source: { id: 'nodeA', handleId: 'h1' }, target: { id: 'nodeB', handleId: 'h2' } };
const result = rule(connection, context);
expect(result).toEqual(ruleResult.notSatisfied("nodes are not allowed to connect to themselves"));
});
});

View File

@@ -1,5 +1,6 @@
import {act} from '@testing-library/react';
import type {Connection, Edge, Node} from "@xyflow/react";
import type {HandleRule, RuleResult} from "../../../../src/pages/VisProgPage/visualProgrammingUI/HandleRuleLogic.ts";
import { NodeDisconnections } from "../../../../src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts";
import type {PhaseNodeData} from "../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx";
import useFlowStore from '../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx';
@@ -594,5 +595,48 @@ describe('FlowStore Functionality', () => {
expect(updatedState.nodes).toHaveLength(1);
expect(updatedState.nodes[0]).toMatchObject(expected.node);
})
})
describe('Handle Rule Registry', () => {
it('should register and retrieve rules', () => {
const mockRules: HandleRule[] = [() => ({ isSatisfied: true } as RuleResult)];
useFlowStore.getState().registerRules('node1', 'handleA', mockRules);
const rules = useFlowStore.getState().getTargetRules('node1', 'handleA');
expect(rules).toEqual(mockRules);
});
it('should warn and return empty array if rules are missing', () => {
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
const rules = useFlowStore.getState().getTargetRules('missingNode', 'missingHandle');
expect(rules).toEqual([]);
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('No rules were registered'));
consoleSpy.mockRestore();
});
it('should unregister a specific handle rule', () => {
const mockRules: HandleRule[] = [() => ({ isSatisfied: true } as RuleResult)];
useFlowStore.getState().registerRules('node1', 'handleA', mockRules);
useFlowStore.getState().unregisterHandleRules('node1', 'handleA');
const rules = useFlowStore.getState().getTargetRules('node1', 'handleA');
expect(rules).toEqual([]);
});
it('should unregister all rules for a node', () => {
const mockRules: HandleRule[] = [() => ({ isSatisfied: true } as RuleResult)];
useFlowStore.getState().registerRules('node1', 'handleA', mockRules);
useFlowStore.getState().registerRules('node1', 'handleB', mockRules);
useFlowStore.getState().registerRules('node2', 'handleC', mockRules);
useFlowStore.getState().unregisterNodeRules('node1');
expect(useFlowStore.getState().getTargetRules('node1', 'handleA')).toEqual([]);
expect(useFlowStore.getState().getTargetRules('node1', 'handleB')).toEqual([]);
expect(useFlowStore.getState().getTargetRules('node2', 'handleC')).toEqual(mockRules);
});
});
})
});

View File

@@ -77,7 +77,8 @@ beforeAll(() => {
past: [],
future: [],
isBatchAction: false,
edgeReconnectSuccessful: true
edgeReconnectSuccessful: true,
ruleRegistry: new Map()
});
});
@@ -89,7 +90,8 @@ afterEach(() => {
past: [],
future: [],
isBatchAction: false,
edgeReconnectSuccessful: true
edgeReconnectSuccessful: true,
ruleRegistry: new Map()
});
});