Conditional Norms

This commit is contained in:
Björn Otgaar
2026-01-07 09:27:23 +00:00
committed by Gerla, J. (Justin)
parent 0ad2d5935f
commit 4e9a048c90
5 changed files with 270 additions and 35 deletions

View File

@@ -28,17 +28,20 @@ import { UndoRedo } from "./EditorUndoRedo.ts";
* @param deletable - Optional flag to indicate if the node can be deleted (can be deleted by default). * @param deletable - Optional flag to indicate if the node can be deleted (can be deleted by default).
* @returns A fully initialized Node object ready to be added to the flow. * @returns A fully initialized Node object ready to be added to the flow.
*/ */
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] const defaultData = NodeDefaults[type as keyof typeof NodeDefaults]
return {
return { id,
id: id, type,
type: type, position,
position: position, deletable,
data: {...defaultData, ...data}, data: {
deletable: deletable ...JSON.parse(JSON.stringify(defaultData)),
...data,
},
}
} }
}
//* Initial nodes, created by using createNode. */ //* Initial nodes, created by using createNode. */
const initialNodes : Node[] = [ const initialNodes : Node[] = [

View File

@@ -6,6 +6,7 @@ import type { NormNodeData } from "./NormNode";
export const NormNodeDefaults: NormNodeData = { export const NormNodeDefaults: NormNodeData = {
label: "Norm Node", label: "Norm Node",
droppable: true, droppable: true,
conditions: [],
norm: "", norm: "",
hasReduce: true, hasReduce: true,
critical: false, critical: false,

View File

@@ -8,6 +8,7 @@ import { Toolbar } from '../components/NodeComponents';
import styles from '../../VisProg.module.css'; import styles from '../../VisProg.module.css';
import { TextField } from '../../../../components/TextField'; import { TextField } from '../../../../components/TextField';
import useFlowStore from '../VisProgStores'; import useFlowStore from '../VisProgStores';
import { BasicBeliefReduce } from './BasicBeliefNode';
/** /**
* The default data dot a phase node * The default data dot a phase node
@@ -19,6 +20,7 @@ import useFlowStore from '../VisProgStores';
export type NormNodeData = { export type NormNodeData = {
label: string; label: string;
droppable: boolean; droppable: boolean;
conditions: string[]; // List of (basic) belief nodes' ids.
norm: string; norm: string;
hasReduce: boolean; hasReduce: boolean;
critical: boolean; critical: boolean;
@@ -67,7 +69,14 @@ export default function NormNode(props: NodeProps<NormNode>) {
onChange={(e) => setCritical(e.target.checked)} onChange={(e) => setCritical(e.target.checked)}
/> />
</div> </div>
{data.conditions.length > 0 && (<div className={"flex-row gap-md align-center"} data-testid="norm-condition-information">
<label htmlFor={checkbox_id}>{data.conditions.length} condition{data.conditions.length > 1 ? "s" : ""}/ belief{data.conditions.length > 1 ? "s" : ""} attached.</label>
</div>)}
<Handle type="source" position={Position.Right} id="norms"/> <Handle type="source" position={Position.Right} id="norms"/>
<Handle type="target" position={Position.Bottom} id="norms"/>
</div> </div>
</>; </>;
}; };
@@ -78,14 +87,29 @@ export default function NormNode(props: NodeProps<NormNode>) {
* @param node The Node Properties of this node. * @param node The Node Properties of this node.
* @param _nodes all the nodes in the graph * @param _nodes all the nodes in the graph
*/ */
export function NormReduce(node: Node, _nodes: Node[]) { export function NormReduce(node: Node, nodes: Node[]) {
const data = node.data as NormNodeData; const data = node.data as NormNodeData;
return {
id: node.id, // conditions nodes - make sure to check for empty arrays
label: data.label, let conditionNodes: Node[] = [];
norm: data.norm, if (data.conditions)
critical: data.critical, conditionNodes = nodes.filter((node) => data.conditions.includes(node.id));
}
// Build the result object
const result: Record<string, unknown> = {
id: node.id,
label: data.label,
norm: data.norm,
critical: data.critical,
};
// Go over our conditionNodes. They should either be Basic (OR TODO: Inferred)
const reducer = BasicBeliefReduce;
result["basic_beliefs"] = conditionNodes.map((condition) => reducer(condition, nodes))
// When the Inferred is being implemented, you should follow the same kind of structure that PhaseNode has,
// dividing the conditions into basic and inferred, then calling the correct reducer on them.
return result
} }
/** /**
@@ -94,7 +118,11 @@ export function NormReduce(node: Node, _nodes: Node[]) {
* @param _sourceNodeId the source of the received connection * @param _sourceNodeId the source of the received connection
*/ */
export function NormConnectionTarget(_thisNode: Node, _sourceNodeId: string) { export function NormConnectionTarget(_thisNode: Node, _sourceNodeId: string) {
// no additional connection logic exists yet const data = _thisNode.data as NormNodeData;
// If we got a belief connected, this is a condition for the norm.
if ((useFlowStore.getState().nodes.find((node) => node.id === _sourceNodeId && node.type === 'basic_belief' /* TODO: Add the option for an inferred belief */))) {
data.conditions.push(_sourceNodeId);
}
} }
/** /**
@@ -112,7 +140,11 @@ export function NormConnectionSource(_thisNode: Node, _targetNodeId: string) {
* @param _sourceNodeId the source of the disconnected connection * @param _sourceNodeId the source of the disconnected connection
*/ */
export function NormDisconnectionTarget(_thisNode: Node, _sourceNodeId: string) { export function NormDisconnectionTarget(_thisNode: Node, _sourceNodeId: string) {
// no additional connection logic exists yet const data = _thisNode.data as NormNodeData;
// If we got a belief connected, this is a condition for the norm.
if ((useFlowStore.getState().nodes.find((node) => node.id === _sourceNodeId && node.type === 'basic_belief' /* TODO: Add the option for an inferred belief */))) {
data.conditions = data.conditions.filter(id => id != _sourceNodeId);
}
} }
/** /**

View File

@@ -10,8 +10,9 @@ import NormNode, {
import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores'; import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores';
import type { Node } from '@xyflow/react'; import type { Node } from '@xyflow/react';
import '@testing-library/jest-dom' import '@testing-library/jest-dom'
import { NormNodeDefaults } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.default.ts';
import { BasicBeliefNodeDefaults } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.default.ts';
import BasicBeliefNode, { BasicBeliefConnectionSource } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx';
describe('NormNode', () => { describe('NormNode', () => {
let user: ReturnType<typeof userEvent.setup>; let user: ReturnType<typeof userEvent.setup>;
@@ -26,12 +27,7 @@ describe('NormNode', () => {
id: 'norm-1', id: 'norm-1',
type: 'norm', type: 'norm',
position: { x: 0, y: 0 }, position: { x: 0, y: 0 },
data: { data: {...JSON.parse(JSON.stringify(NormNodeDefaults))},
label: 'Test Norm',
droppable: true,
norm: '',
hasReduce: true,
},
}; };
renderWithProviders( renderWithProviders(
@@ -60,6 +56,7 @@ describe('NormNode', () => {
type: 'norm', type: 'norm',
position: { x: 0, y: 0 }, position: { x: 0, y: 0 },
data: { data: {
...JSON.parse(JSON.stringify(NormNodeDefaults)),
label: 'Test Norm', label: 'Test Norm',
droppable: true, droppable: true,
norm: 'Be respectful to humans', norm: 'Be respectful to humans',
@@ -94,8 +91,10 @@ describe('NormNode', () => {
type: 'norm', type: 'norm',
position: { x: 0, y: 0 }, position: { x: 0, y: 0 },
data: { data: {
...JSON.parse(JSON.stringify(NormNodeDefaults)),
label: 'Test Norm', label: 'Test Norm',
droppable: true, droppable: true,
conditions: [],
norm: '', norm: '',
hasReduce: true, hasReduce: true,
critical: false critical: false
@@ -129,6 +128,7 @@ describe('NormNode', () => {
type: 'norm', type: 'norm',
position: { x: 0, y: 0 }, position: { x: 0, y: 0 },
data: { data: {
...JSON.parse(JSON.stringify(NormNodeDefaults)),
label: 'Test Norm', label: 'Test Norm',
droppable: true, droppable: true,
norm: 'Dragged norm', norm: 'Dragged norm',
@@ -165,6 +165,7 @@ describe('NormNode', () => {
type: 'norm', type: 'norm',
position: { x: 0, y: 0 }, position: { x: 0, y: 0 },
data: { data: {
...JSON.parse(JSON.stringify(NormNodeDefaults)),
label: 'Test Norm', label: 'Test Norm',
droppable: true, droppable: true,
norm: '', norm: '',
@@ -210,6 +211,7 @@ describe('NormNode', () => {
type: 'norm', type: 'norm',
position: { x: 0, y: 0 }, position: { x: 0, y: 0 },
data: { data: {
...JSON.parse(JSON.stringify(NormNodeDefaults)),
label: 'Test Norm', label: 'Test Norm',
droppable: true, droppable: true,
norm: 'Initial norm text', norm: 'Initial norm text',
@@ -261,6 +263,7 @@ describe('NormNode', () => {
type: 'norm', type: 'norm',
position: { x: 0, y: 0 }, position: { x: 0, y: 0 },
data: { data: {
...JSON.parse(JSON.stringify(NormNodeDefaults)),
label: 'Test Norm', label: 'Test Norm',
droppable: true, droppable: true,
norm: '', norm: '',
@@ -314,6 +317,7 @@ describe('NormNode', () => {
type: 'norm', type: 'norm',
position: { x: 0, y: 0 }, position: { x: 0, y: 0 },
data: { data: {
...JSON.parse(JSON.stringify(NormNodeDefaults)),
label: 'Test Norm', label: 'Test Norm',
droppable: true, droppable: true,
norm: '', norm: '',
@@ -358,6 +362,7 @@ describe('NormNode', () => {
type: 'norm', type: 'norm',
position: { x: 0, y: 0 }, position: { x: 0, y: 0 },
data: { data: {
...JSON.parse(JSON.stringify(NormNodeDefaults)),
label: 'Test Norm', label: 'Test Norm',
droppable: true, droppable: true,
norm: '', norm: '',
@@ -404,6 +409,7 @@ describe('NormNode', () => {
type: 'norm', type: 'norm',
position: { x: 0, y: 0 }, position: { x: 0, y: 0 },
data: { data: {
...JSON.parse(JSON.stringify(NormNodeDefaults)),
label: 'Safety Norm', label: 'Safety Norm',
droppable: true, droppable: true,
norm: 'Never harm humans', norm: 'Never harm humans',
@@ -418,6 +424,8 @@ describe('NormNode', () => {
id: 'norm-1', id: 'norm-1',
label: 'Safety Norm', label: 'Safety Norm',
norm: 'Never harm humans', norm: 'Never harm humans',
critical: false,
basic_beliefs: [],
}); });
}); });
@@ -427,6 +435,7 @@ describe('NormNode', () => {
type: 'norm', type: 'norm',
position: { x: 0, y: 0 }, position: { x: 0, y: 0 },
data: { data: {
...JSON.parse(JSON.stringify(NormNodeDefaults)),
label: 'Norm 1', label: 'Norm 1',
droppable: true, droppable: true,
norm: 'Be helpful', norm: 'Be helpful',
@@ -439,6 +448,7 @@ describe('NormNode', () => {
type: 'norm', type: 'norm',
position: { x: 100, y: 0 }, position: { x: 100, y: 0 },
data: { data: {
...JSON.parse(JSON.stringify(NormNodeDefaults)),
label: 'Norm 2', label: 'Norm 2',
droppable: true, droppable: true,
norm: 'Be honest', norm: 'Be honest',
@@ -463,6 +473,7 @@ describe('NormNode', () => {
type: 'norm', type: 'norm',
position: { x: 0, y: 0 }, position: { x: 0, y: 0 },
data: { data: {
...JSON.parse(JSON.stringify(NormNodeDefaults)),
label: 'Empty Norm', label: 'Empty Norm',
droppable: true, droppable: true,
norm: '', norm: '',
@@ -482,6 +493,7 @@ describe('NormNode', () => {
type: 'norm', type: 'norm',
position: { x: 0, y: 0 }, position: { x: 0, y: 0 },
data: { data: {
...JSON.parse(JSON.stringify(NormNodeDefaults)),
label: 'Custom Label', label: 'Custom Label',
droppable: false, droppable: false,
norm: 'Test norm', norm: 'Test norm',
@@ -502,6 +514,7 @@ describe('NormNode', () => {
type: 'norm', type: 'norm',
position: { x: 0, y: 0 }, position: { x: 0, y: 0 },
data: { data: {
...JSON.parse(JSON.stringify(NormNodeDefaults)),
label: 'Test Norm', label: 'Test Norm',
droppable: true, droppable: true,
norm: 'Test', norm: 'Test',
@@ -514,6 +527,7 @@ describe('NormNode', () => {
type: 'phase', type: 'phase',
position: { x: 100, y: 0 }, position: { x: 100, y: 0 },
data: { data: {
...JSON.parse(JSON.stringify(NormNodeDefaults)),
label: 'Phase 1', label: 'Phase 1',
droppable: true, droppable: true,
children: [], children: [],
@@ -532,6 +546,7 @@ describe('NormNode', () => {
type: 'norm', type: 'norm',
position: { x: 0, y: 0 }, position: { x: 0, y: 0 },
data: { data: {
...JSON.parse(JSON.stringify(NormNodeDefaults)),
label: 'Test Norm', label: 'Test Norm',
droppable: true, droppable: true,
norm: 'Test', norm: 'Test',
@@ -544,6 +559,7 @@ describe('NormNode', () => {
type: 'phase', type: 'phase',
position: { x: 100, y: 0 }, position: { x: 100, y: 0 },
data: { data: {
...JSON.parse(JSON.stringify(NormNodeDefaults)),
label: 'Phase 1', label: 'Phase 1',
droppable: true, droppable: true,
children: [], children: [],
@@ -562,6 +578,7 @@ describe('NormNode', () => {
type: 'norm', type: 'norm',
position: { x: 0, y: 0 }, position: { x: 0, y: 0 },
data: { data: {
...NormNodeDefaults,
label: 'Test Norm', label: 'Test Norm',
droppable: true, droppable: true,
norm: 'Test', norm: 'Test',
@@ -583,6 +600,7 @@ describe('NormNode', () => {
type: 'norm', type: 'norm',
position: { x: 0, y: 0 }, position: { x: 0, y: 0 },
data: { data: {
...JSON.parse(JSON.stringify(NormNodeDefaults)),
label: 'Test Norm', label: 'Test Norm',
droppable: true, droppable: true,
norm: '', norm: '',
@@ -634,6 +652,7 @@ describe('NormNode', () => {
type: 'norm', type: 'norm',
position: { x: 0, y: 0 }, position: { x: 0, y: 0 },
data: { data: {
...JSON.parse(JSON.stringify(NormNodeDefaults)),
label: 'Test Norm', label: 'Test Norm',
droppable: true, droppable: true,
norm: '', norm: '',
@@ -682,6 +701,7 @@ describe('NormNode', () => {
type: 'norm', type: 'norm',
position: { x: 0, y: 0 }, position: { x: 0, y: 0 },
data: { data: {
...JSON.parse(JSON.stringify(NormNodeDefaults)),
label: 'Norm 1', label: 'Norm 1',
droppable: true, droppable: true,
norm: 'Original norm 1', norm: 'Original norm 1',
@@ -694,6 +714,7 @@ describe('NormNode', () => {
type: 'norm', type: 'norm',
position: { x: 100, y: 0 }, position: { x: 100, y: 0 },
data: { data: {
...JSON.parse(JSON.stringify(NormNodeDefaults)),
label: 'Norm 2', label: 'Norm 2',
droppable: true, droppable: true,
norm: 'Original norm 2', norm: 'Original norm 2',
@@ -748,6 +769,7 @@ describe('NormNode', () => {
type: 'norm', type: 'norm',
position: { x: 0, y: 0 }, position: { x: 0, y: 0 },
data: { data: {
...NormNodeDefaults,
label: 'Test Norm', label: 'Test Norm',
droppable: true, droppable: true,
norm: 'haa haa fuyaaah - link', norm: 'haa haa fuyaaah - link',
@@ -778,21 +800,154 @@ describe('NormNode', () => {
); );
const input = screen.getByPlaceholderText('Pepper should ...'); const input = screen.getByPlaceholderText('Pepper should ...');
expect(input).toBeDefined()
await user.type(input, 'a'); await user.type(input, 'a{enter}');
await waitFor(() => { await waitFor(() => {
expect(useFlowStore.getState().nodes[0].data.norm).toBe('haa haa fuyaaah - link'); expect(useFlowStore.getState().nodes[0].data.norm).toBe('haa haa fuyaaah - linka');
}); });
await user.type(input, 'b'); await user.type(input, 'b{enter}');
await waitFor(() => { await waitFor(() => {
expect(useFlowStore.getState().nodes[0].data.norm).toBe('haa haa fuyaaah - link'); expect(useFlowStore.getState().nodes[0].data.norm).toBe('haa haa fuyaaah - linkab');
}); });
await user.type(input, 'c'); await user.type(input, 'c{enter}');
await waitFor(() => { await waitFor(() => {
expect(useFlowStore.getState().nodes[0].data.norm).toBe('haa haa fuyaaah - link'); expect(useFlowStore.getState().nodes[0].data.norm).toBe('haa haa fuyaaah - linkabc');
}, { timeout: 3000 }); });
});
});
describe('Integration beliefs', () => {
it('should update visually when adding beliefs', async () => {
// Setup state
const mockNode: Node = {
id: 'norm-1',
type: 'norm',
position: { x: 0, y: 0 },
data: {
...JSON.parse(JSON.stringify(NormNodeDefaults)),
label: 'Test Norm',
droppable: true,
norm: 'haa haa fuyaaah - link',
hasReduce: true,
}
};
const mockBelief: Node = {
id: 'basic_belief-1',
type: 'basic_belief',
position: {x:100, y:100},
data: {
...JSON.parse(JSON.stringify(BasicBeliefNodeDefaults))
}
};
useFlowStore.setState({
nodes: [mockNode, mockBelief],
edges: [],
});
// Simulate connecting
NormConnectionTarget(mockNode, mockBelief.id);
BasicBeliefConnectionSource(mockBelief, mockNode.id)
renderWithProviders(
<div>
<NormNode
id={mockNode.id}
type={mockNode.type as string}
data={mockNode.data as any}
selected={false}
isConnectable={true}
zIndex={0}
dragging={false}
selectable={true}
deletable={true}
draggable={true}
positionAbsoluteX={0}
positionAbsoluteY={0}
/>
<BasicBeliefNode
id={mockBelief.id}
type={mockBelief.type as string}
data={mockBelief.data as any}
selected={false}
isConnectable={true}
zIndex={0}
dragging={false}
selectable={true}
deletable={true}
draggable={true}
positionAbsoluteX={0}
positionAbsoluteY={0}
/>
</div>
);
await waitFor(() => {
expect(screen.getByTestId('norm-condition-information')).toBeInTheDocument();
});
});
it('should update the data when adding beliefs', async () => {
// Setup state
const mockNode: Node = {
id: 'norm-1',
type: 'norm',
position: { x: 0, y: 0 },
data: {
...JSON.parse(JSON.stringify(NormNodeDefaults)),
label: 'Test Norm',
droppable: true,
norm: 'haa haa fuyaaah - link',
hasReduce: true,
}
};
const mockBelief1: Node = {
id: 'basic_belief-1',
type: 'basic_belief',
position: {x:100, y:100},
data: {
...JSON.parse(JSON.stringify(BasicBeliefNodeDefaults))
}
};
const mockBelief2: Node = {
id: 'basic_belief-2',
type: 'basic_belief',
position: {x:300, y:300},
data: {
...JSON.parse(JSON.stringify(BasicBeliefNodeDefaults))
}
};
useFlowStore.setState({
nodes: [mockNode, mockBelief1, mockBelief2],
edges: [],
});
// Simulate connecting
useFlowStore.getState().onConnect({
source: 'basic_belief-1',
target: 'norm-1',
sourceHandle: null,
targetHandle: null,
});
useFlowStore.getState().onConnect({
source: 'basic_belief-2',
target: 'norm-1',
sourceHandle: null,
targetHandle: null,
});
const state = useFlowStore.getState();
const updatedNorm = state.nodes.find(n => n.id === 'norm-1');
expect(updatedNorm?.data.conditions).toEqual(["basic_belief-1", "basic_belief-2"]);
}); });
}); });
}); });

View File

@@ -8,7 +8,7 @@ import { createElement } from 'react';
import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores'; import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores';
describe('NormNode', () => { describe('Universal Nodes', () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
}); });
@@ -107,6 +107,50 @@ describe('NormNode', () => {
}); });
}); });
describe('Disconnecting', () => {
test.each(getAllTypes())('it should remove the correct data when something is disconnected on a %s node.', (nodeType) => {
// Create two nodes - one of the current type and one to connect to
const sourceNode = createNode('source-1', nodeType, {x: 100, y: 100}, {});
const targetNode = createNode('target-1', 'basic_belief', {x: 300, y: 100}, {});
// Add nodes to store
useFlowStore.setState({ nodes: [sourceNode, targetNode] });
// Spy on the connect functions
const sourceConnectSpy = jest.spyOn(NodeConnections.Sources, nodeType as keyof typeof NodeConnections.Sources);
const targetConnectSpy = jest.spyOn(NodeConnections.Targets, 'basic_belief');
// Simulate connection
useFlowStore.getState().onConnect({
source: 'source-1',
target: 'target-1',
sourceHandle: null,
targetHandle: null,
});
// Verify the connect functions were called
expect(sourceConnectSpy).toHaveBeenCalledWith(sourceNode, targetNode.id);
expect(targetConnectSpy).toHaveBeenCalledWith(targetNode, sourceNode.id);
// Find this connection, and delete it
const edge = useFlowStore.getState().edges[0];
useFlowStore.getState().onEdgesDelete([edge]);
// Find the nodes in the flow
const newSourceNode = useFlowStore.getState().nodes.find((node) => node.id == "source-1");
const newTargetNode = useFlowStore.getState().nodes.find((node) => node.id == "target-1");
// Expect them to be the same after deleting the edges
expect(newSourceNode).toBe(sourceNode);
expect(newTargetNode).toBe(targetNode);
// Restore our spies
sourceConnectSpy.mockRestore();
targetConnectSpy.mockRestore();
});
});
describe('Reducing', () => { describe('Reducing', () => {
test.each(getAllTypes())('it should correctly call/ not call the reduce function when %s node is in a phase', (nodeType) => { test.each(getAllTypes())('it should correctly call/ not call the reduce function when %s node is in a phase', (nodeType) => {
// Create a phase node and a node of the current type // Create a phase node and a node of the current type