Files
OpenNoodl/packages/noodl-runtime/test/node-expression-evaluation.test.js
Richard Osborne 6f08163590 new code editor
2026-01-11 09:48:20 +01:00

346 lines
12 KiB
JavaScript

/**
* Node Expression Evaluation Tests
*
* Tests the integration of expression parameters with the Node base class.
* Verifies that expressions are evaluated correctly and results are type-coerced.
*
* @jest-environment jsdom
*/
/* eslint-env jest */
const Node = require('../src/node');
// Helper to create expression parameter
function createExpressionParameter(expression, fallback, version = 1) {
return {
mode: 'expression',
expression,
fallback,
version
};
}
describe('Node Expression Evaluation', () => {
let mockContext;
let node;
beforeEach(() => {
// Create mock context with Variables
mockContext = {
updateIteration: 0,
nodeIsDirty: jest.fn(),
styles: {
resolveColor: jest.fn((color) => color)
},
editorConnection: {
sendWarning: jest.fn(),
clearWarning: jest.fn()
},
getDefaultValueForInput: jest.fn(() => undefined),
Variables: {
x: 10,
count: 5,
isAdmin: true,
message: 'Hello'
}
};
// Create a test node
node = new Node(mockContext, 'test-node-1');
node.name = 'TestNode';
node.nodeScope = {
componentOwner: { name: 'TestComponent' }
};
// Register test inputs with different types
node.registerInputs({
numberInput: {
type: 'number',
default: 0,
set: jest.fn()
},
stringInput: {
type: 'string',
default: '',
set: jest.fn()
},
booleanInput: {
type: 'boolean',
default: false,
set: jest.fn()
},
colorInput: {
type: 'color',
default: '#000000',
set: jest.fn()
},
anyInput: {
type: undefined,
default: null,
set: jest.fn()
}
});
});
describe('_evaluateExpressionParameter', () => {
describe('Basic evaluation', () => {
it('returns simple values as-is', () => {
expect(node._evaluateExpressionParameter(42, 'numberInput')).toBe(42);
expect(node._evaluateExpressionParameter('hello', 'stringInput')).toBe('hello');
expect(node._evaluateExpressionParameter(true, 'booleanInput')).toBe(true);
expect(node._evaluateExpressionParameter(null, 'anyInput')).toBe(null);
expect(node._evaluateExpressionParameter(undefined, 'anyInput')).toBe(undefined);
});
it('evaluates expression parameters', () => {
const expr = createExpressionParameter('10 + 5', 0);
const result = node._evaluateExpressionParameter(expr, 'numberInput');
expect(result).toBe(15);
});
it('uses fallback on evaluation error', () => {
const expr = createExpressionParameter('undefined.foo', 100);
const result = node._evaluateExpressionParameter(expr, 'numberInput');
expect(result).toBe(100);
});
it('uses fallback when no input definition exists', () => {
const expr = createExpressionParameter('10 + 5', 999);
const result = node._evaluateExpressionParameter(expr, 'nonexistentInput');
expect(result).toBe(999);
});
it('coerces result to expected port type', () => {
const expr = createExpressionParameter('"42"', 0); // String expression
const result = node._evaluateExpressionParameter(expr, 'numberInput');
expect(result).toBe(42); // Coerced to number
expect(typeof result).toBe('number');
});
});
describe('Type coercion integration', () => {
it('coerces string expressions to numbers', () => {
const expr = createExpressionParameter('"123"', 0);
const result = node._evaluateExpressionParameter(expr, 'numberInput');
expect(result).toBe(123);
});
it('coerces number expressions to strings', () => {
const expr = createExpressionParameter('456', '');
const result = node._evaluateExpressionParameter(expr, 'stringInput');
expect(result).toBe('456');
expect(typeof result).toBe('string');
});
it('coerces boolean expressions correctly', () => {
const expr = createExpressionParameter('1', false);
const result = node._evaluateExpressionParameter(expr, 'booleanInput');
expect(result).toBe(true);
});
it('validates color expressions', () => {
const expr = createExpressionParameter('"#ff0000"', '#000000');
const result = node._evaluateExpressionParameter(expr, 'colorInput');
expect(result).toBe('#ff0000');
});
it('uses fallback for invalid color expressions', () => {
const expr = createExpressionParameter('"not-a-color"', '#000000');
const result = node._evaluateExpressionParameter(expr, 'colorInput');
expect(result).toBe('#000000');
});
});
describe('Error handling', () => {
it('handles syntax errors gracefully', () => {
const expr = createExpressionParameter('10 +', 0);
const result = node._evaluateExpressionParameter(expr, 'numberInput');
expect(result).toBe(0); // Fallback
});
it('handles reference errors gracefully', () => {
const expr = createExpressionParameter('unknownVariable', 0);
const result = node._evaluateExpressionParameter(expr, 'numberInput');
expect(result).toBe(0); // Fallback
});
it('sends warning to editor on error', () => {
const expr = createExpressionParameter('undefined.foo', 0);
node._evaluateExpressionParameter(expr, 'numberInput');
expect(mockContext.editorConnection.sendWarning).toHaveBeenCalledWith(
'TestComponent',
'test-node-1',
'expression-error-numberInput',
expect.objectContaining({
showGlobally: true,
message: expect.stringContaining('Expression error')
})
);
});
it('clears warnings on successful evaluation', () => {
const expr = createExpressionParameter('10 + 5', 0);
node._evaluateExpressionParameter(expr, 'numberInput');
expect(mockContext.editorConnection.clearWarning).toHaveBeenCalledWith(
'TestComponent',
'test-node-1',
'expression-error-numberInput'
);
});
});
describe('Context integration', () => {
it('has access to Variables', () => {
const expr = createExpressionParameter('Variables.x * 2', 0);
const result = node._evaluateExpressionParameter(expr, 'numberInput');
expect(result).toBe(20); // Variables.x = 10, * 2 = 20
});
it('evaluates complex expressions with Variables', () => {
const expr = createExpressionParameter('Variables.isAdmin ? "Admin" : "User"', 'User');
const result = node._evaluateExpressionParameter(expr, 'stringInput');
expect(result).toBe('Admin'); // Variables.isAdmin = true
});
it('handles arithmetic with Variables', () => {
const expr = createExpressionParameter('Variables.count + Variables.x', 0);
const result = node._evaluateExpressionParameter(expr, 'numberInput');
expect(result).toBe(15); // 5 + 10 = 15
});
});
describe('Edge cases', () => {
it('handles undefined fallback', () => {
const expr = createExpressionParameter('invalid syntax +', undefined);
const result = node._evaluateExpressionParameter(expr, 'anyInput');
expect(result).toBeUndefined();
});
it('handles null expression result', () => {
const expr = createExpressionParameter('null', 'fallback');
const result = node._evaluateExpressionParameter(expr, 'stringInput');
expect(result).toBe('null'); // Coerced to string
});
it('handles complex object expressions', () => {
mockContext.data = { items: [1, 2, 3] };
const expr = createExpressionParameter('data.items.length', 0);
node.context = mockContext;
const result = node._evaluateExpressionParameter(expr, 'numberInput');
expect(result).toBe(3);
});
it('handles empty string expression', () => {
const expr = createExpressionParameter('', 'fallback');
const result = node._evaluateExpressionParameter(expr, 'stringInput');
// Empty expression evaluates to undefined, uses fallback
expect(result).toBe('fallback');
});
it('handles multi-line expressions', () => {
const expr = createExpressionParameter(
`Variables.x > 5 ?
"Greater" :
"Lesser"`,
'Unknown'
);
const result = node._evaluateExpressionParameter(expr, 'stringInput');
expect(result).toBe('Greater');
});
});
});
describe('setInputValue with expressions', () => {
describe('Integration with input setters', () => {
it('evaluates expressions before calling input setter', () => {
const expr = createExpressionParameter('Variables.x * 2', 0);
node.setInputValue('numberInput', expr);
const input = node.getInput('numberInput');
expect(input.set).toHaveBeenCalledWith(20); // Evaluated result
});
it('passes simple values directly to setter', () => {
node.setInputValue('numberInput', 42);
const input = node.getInput('numberInput');
expect(input.set).toHaveBeenCalledWith(42);
});
it('stores evaluated value in _inputValues', () => {
const expr = createExpressionParameter('Variables.count', 0);
node.setInputValue('numberInput', expr);
// _inputValues should store the expression, not the evaluated result
// (This allows re-evaluation on context changes)
expect(node._inputValues['numberInput']).toEqual(expr);
});
it('works with string input type', () => {
const expr = createExpressionParameter('Variables.message', 'default');
node.setInputValue('stringInput', expr);
const input = node.getInput('stringInput');
expect(input.set).toHaveBeenCalledWith('Hello');
});
it('works with boolean input type', () => {
const expr = createExpressionParameter('Variables.isAdmin', false);
node.setInputValue('booleanInput', expr);
const input = node.getInput('booleanInput');
expect(input.set).toHaveBeenCalledWith(true);
});
});
describe('Maintains existing behavior', () => {
it('maintains existing unit handling', () => {
// Set initial value with unit
node.setInputValue('numberInput', { value: 10, unit: 'px' });
// Update with unitless value
node.setInputValue('numberInput', 20);
const input = node.getInput('numberInput');
expect(input.set).toHaveBeenLastCalledWith({ value: 20, unit: 'px' });
});
it('maintains existing color resolution', () => {
mockContext.styles.resolveColor = jest.fn((color) => '#resolved');
node.setInputValue('colorInput', '#ff0000');
const input = node.getInput('colorInput');
expect(input.set).toHaveBeenCalledWith('#resolved');
});
it('handles non-existent input gracefully', () => {
// Should not throw
expect(() => {
node.setInputValue('nonexistent', 42);
}).not.toThrow();
});
});
describe('Expression evaluation errors', () => {
it('uses fallback when expression fails', () => {
const expr = createExpressionParameter('undefined.prop', 999);
node.setInputValue('numberInput', expr);
const input = node.getInput('numberInput');
expect(input.set).toHaveBeenCalledWith(999); // Fallback
});
it('sends warning on expression error', () => {
const expr = createExpressionParameter('syntax error +', 0);
node.setInputValue('numberInput', expr);
expect(mockContext.editorConnection.sendWarning).toHaveBeenCalled();
});
});
});
});