mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-03-08 01:53:30 +01:00
new code editor
This commit is contained in:
357
packages/noodl-runtime/test/expression-evaluator.test.js
Normal file
357
packages/noodl-runtime/test/expression-evaluator.test.js
Normal file
@@ -0,0 +1,357 @@
|
||||
const ExpressionEvaluator = require('../src/expression-evaluator');
|
||||
const Model = require('../src/model');
|
||||
|
||||
describe('Expression Evaluator', () => {
|
||||
beforeEach(() => {
|
||||
// Reset Model state before each test
|
||||
Model._models = {};
|
||||
// Ensure global variables model exists
|
||||
Model.get('--ndl--global-variables');
|
||||
ExpressionEvaluator.clearCache();
|
||||
});
|
||||
|
||||
describe('detectDependencies', () => {
|
||||
it('detects Noodl.Variables references', () => {
|
||||
const deps = ExpressionEvaluator.detectDependencies(
|
||||
'Noodl.Variables.isLoggedIn ? Noodl.Variables.userName : "guest"'
|
||||
);
|
||||
expect(deps.variables).toContain('isLoggedIn');
|
||||
expect(deps.variables).toContain('userName');
|
||||
expect(deps.variables.length).toBe(2);
|
||||
});
|
||||
|
||||
it('detects Variables shorthand references', () => {
|
||||
const deps = ExpressionEvaluator.detectDependencies('Variables.count + Variables.offset');
|
||||
expect(deps.variables).toContain('count');
|
||||
expect(deps.variables).toContain('offset');
|
||||
});
|
||||
|
||||
it('detects bracket notation', () => {
|
||||
const deps = ExpressionEvaluator.detectDependencies('Noodl.Variables["my variable"]');
|
||||
expect(deps.variables).toContain('my variable');
|
||||
});
|
||||
|
||||
it('ignores references inside strings', () => {
|
||||
const deps = ExpressionEvaluator.detectDependencies('"Noodl.Variables.notReal"');
|
||||
expect(deps.variables).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('detects Noodl.Objects references', () => {
|
||||
const deps = ExpressionEvaluator.detectDependencies('Noodl.Objects.CurrentUser.name');
|
||||
expect(deps.objects).toContain('CurrentUser');
|
||||
});
|
||||
|
||||
it('detects Objects shorthand references', () => {
|
||||
const deps = ExpressionEvaluator.detectDependencies('Objects.User.id');
|
||||
expect(deps.objects).toContain('User');
|
||||
});
|
||||
|
||||
it('detects Noodl.Arrays references', () => {
|
||||
const deps = ExpressionEvaluator.detectDependencies('Noodl.Arrays.items.length');
|
||||
expect(deps.arrays).toContain('items');
|
||||
});
|
||||
|
||||
it('detects Arrays shorthand references', () => {
|
||||
const deps = ExpressionEvaluator.detectDependencies('Arrays.todos.filter(x => x.done)');
|
||||
expect(deps.arrays).toContain('todos');
|
||||
});
|
||||
|
||||
it('handles mixed dependencies', () => {
|
||||
const deps = ExpressionEvaluator.detectDependencies(
|
||||
'Variables.isAdmin && Objects.User.role === "admin" ? Arrays.items.length : 0'
|
||||
);
|
||||
expect(deps.variables).toContain('isAdmin');
|
||||
expect(deps.objects).toContain('User');
|
||||
expect(deps.arrays).toContain('items');
|
||||
});
|
||||
|
||||
it('handles template literals', () => {
|
||||
const deps = ExpressionEvaluator.detectDependencies('`Hello, ${Variables.userName}!`');
|
||||
expect(deps.variables).toContain('userName');
|
||||
});
|
||||
});
|
||||
|
||||
describe('compileExpression', () => {
|
||||
it('compiles valid expression', () => {
|
||||
const fn = ExpressionEvaluator.compileExpression('1 + 1');
|
||||
expect(fn).not.toBeNull();
|
||||
expect(typeof fn).toBe('function');
|
||||
});
|
||||
|
||||
it('returns null for invalid expression', () => {
|
||||
const fn = ExpressionEvaluator.compileExpression('1 +');
|
||||
expect(fn).toBeNull();
|
||||
});
|
||||
|
||||
it('caches compiled functions', () => {
|
||||
const fn1 = ExpressionEvaluator.compileExpression('2 + 2');
|
||||
const fn2 = ExpressionEvaluator.compileExpression('2 + 2');
|
||||
expect(fn1).toBe(fn2);
|
||||
});
|
||||
|
||||
it('different expressions compile separately', () => {
|
||||
const fn1 = ExpressionEvaluator.compileExpression('1 + 1');
|
||||
const fn2 = ExpressionEvaluator.compileExpression('2 + 2');
|
||||
expect(fn1).not.toBe(fn2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateExpression', () => {
|
||||
it('validates correct syntax', () => {
|
||||
const result = ExpressionEvaluator.validateExpression('a > b ? 1 : 0');
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.error).toBeNull();
|
||||
});
|
||||
|
||||
it('catches syntax errors', () => {
|
||||
const result = ExpressionEvaluator.validateExpression('a >');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('validates complex expressions', () => {
|
||||
const result = ExpressionEvaluator.validateExpression('Variables.count > 10 ? "many" : "few"');
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('evaluateExpression', () => {
|
||||
it('evaluates simple math expressions', () => {
|
||||
const fn = ExpressionEvaluator.compileExpression('5 + 3');
|
||||
const result = ExpressionEvaluator.evaluateExpression(fn);
|
||||
expect(result).toBe(8);
|
||||
});
|
||||
|
||||
it('evaluates with min/max helpers', () => {
|
||||
const fn = ExpressionEvaluator.compileExpression('min(10, 5) + max(1, 2)');
|
||||
const result = ExpressionEvaluator.evaluateExpression(fn);
|
||||
expect(result).toBe(7);
|
||||
});
|
||||
|
||||
it('evaluates with pi constant', () => {
|
||||
const fn = ExpressionEvaluator.compileExpression('round(pi * 100) / 100');
|
||||
const result = ExpressionEvaluator.evaluateExpression(fn);
|
||||
expect(result).toBe(3.14);
|
||||
});
|
||||
|
||||
it('evaluates with pow helper', () => {
|
||||
const fn = ExpressionEvaluator.compileExpression('pow(2, 3)');
|
||||
const result = ExpressionEvaluator.evaluateExpression(fn);
|
||||
expect(result).toBe(8);
|
||||
});
|
||||
|
||||
it('returns undefined for null function', () => {
|
||||
const result = ExpressionEvaluator.evaluateExpression(null);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('evaluates with Noodl.Variables', () => {
|
||||
const varsModel = Model.get('--ndl--global-variables');
|
||||
varsModel.set('testVar', 42);
|
||||
|
||||
const fn = ExpressionEvaluator.compileExpression('Variables.testVar * 2');
|
||||
const result = ExpressionEvaluator.evaluateExpression(fn);
|
||||
expect(result).toBe(84);
|
||||
});
|
||||
|
||||
it('evaluates with Noodl.Objects', () => {
|
||||
const userModel = Model.get('CurrentUser');
|
||||
userModel.set('name', 'Alice');
|
||||
|
||||
const fn = ExpressionEvaluator.compileExpression('Objects.CurrentUser.name');
|
||||
const result = ExpressionEvaluator.evaluateExpression(fn);
|
||||
expect(result).toBe('Alice');
|
||||
});
|
||||
|
||||
it('handles undefined Variables gracefully', () => {
|
||||
const fn = ExpressionEvaluator.compileExpression('Variables.nonExistent || "default"');
|
||||
const result = ExpressionEvaluator.evaluateExpression(fn);
|
||||
expect(result).toBe('default');
|
||||
});
|
||||
|
||||
it('evaluates ternary expressions', () => {
|
||||
const varsModel = Model.get('--ndl--global-variables');
|
||||
varsModel.set('isAdmin', true);
|
||||
|
||||
const fn = ExpressionEvaluator.compileExpression('Variables.isAdmin ? "Admin" : "User"');
|
||||
const result = ExpressionEvaluator.evaluateExpression(fn);
|
||||
expect(result).toBe('Admin');
|
||||
});
|
||||
|
||||
it('evaluates template literals', () => {
|
||||
const varsModel = Model.get('--ndl--global-variables');
|
||||
varsModel.set('name', 'Bob');
|
||||
|
||||
const fn = ExpressionEvaluator.compileExpression('`Hello, ${Variables.name}!`');
|
||||
const result = ExpressionEvaluator.evaluateExpression(fn);
|
||||
expect(result).toBe('Hello, Bob!');
|
||||
});
|
||||
});
|
||||
|
||||
describe('subscribeToChanges', () => {
|
||||
it('calls callback when Variable changes', (done) => {
|
||||
const varsModel = Model.get('--ndl--global-variables');
|
||||
varsModel.set('counter', 0);
|
||||
|
||||
const deps = { variables: ['counter'], objects: [], arrays: [] };
|
||||
const unsub = ExpressionEvaluator.subscribeToChanges(deps, () => {
|
||||
unsub();
|
||||
done();
|
||||
});
|
||||
|
||||
varsModel.set('counter', 1);
|
||||
});
|
||||
|
||||
it('calls callback when Object changes', (done) => {
|
||||
const userModel = Model.get('TestUser');
|
||||
userModel.set('name', 'Initial');
|
||||
|
||||
const deps = { variables: [], objects: ['TestUser'], arrays: [] };
|
||||
const unsub = ExpressionEvaluator.subscribeToChanges(deps, () => {
|
||||
unsub();
|
||||
done();
|
||||
});
|
||||
|
||||
userModel.set('name', 'Changed');
|
||||
});
|
||||
|
||||
it('does not call callback for unrelated Variable changes', () => {
|
||||
const varsModel = Model.get('--ndl--global-variables');
|
||||
let called = false;
|
||||
|
||||
const deps = { variables: ['watchThis'], objects: [], arrays: [] };
|
||||
const unsub = ExpressionEvaluator.subscribeToChanges(deps, () => {
|
||||
called = true;
|
||||
});
|
||||
|
||||
varsModel.set('notWatching', 'value');
|
||||
|
||||
setTimeout(() => {
|
||||
expect(called).toBe(false);
|
||||
unsub();
|
||||
}, 50);
|
||||
});
|
||||
|
||||
it('unsubscribe prevents future callbacks', () => {
|
||||
const varsModel = Model.get('--ndl--global-variables');
|
||||
let callCount = 0;
|
||||
|
||||
const deps = { variables: ['test'], objects: [], arrays: [] };
|
||||
const unsub = ExpressionEvaluator.subscribeToChanges(deps, () => {
|
||||
callCount++;
|
||||
});
|
||||
|
||||
varsModel.set('test', 1);
|
||||
unsub();
|
||||
varsModel.set('test', 2);
|
||||
|
||||
setTimeout(() => {
|
||||
expect(callCount).toBe(1);
|
||||
}, 50);
|
||||
});
|
||||
|
||||
it('handles multiple dependencies', (done) => {
|
||||
const varsModel = Model.get('--ndl--global-variables');
|
||||
const userModel = Model.get('User');
|
||||
let callCount = 0;
|
||||
|
||||
const deps = { variables: ['count'], objects: ['User'], arrays: [] };
|
||||
const unsub = ExpressionEvaluator.subscribeToChanges(deps, () => {
|
||||
callCount++;
|
||||
if (callCount === 2) {
|
||||
unsub();
|
||||
done();
|
||||
}
|
||||
});
|
||||
|
||||
varsModel.set('count', 1);
|
||||
userModel.set('name', 'Test');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createNoodlContext', () => {
|
||||
it('creates context with Variables', () => {
|
||||
const varsModel = Model.get('--ndl--global-variables');
|
||||
varsModel.set('test', 123);
|
||||
|
||||
const context = ExpressionEvaluator.createNoodlContext();
|
||||
expect(context.Variables.test).toBe(123);
|
||||
});
|
||||
|
||||
it('creates context with Objects proxy', () => {
|
||||
const userModel = Model.get('TestUser');
|
||||
userModel.set('id', 'user-1');
|
||||
|
||||
const context = ExpressionEvaluator.createNoodlContext();
|
||||
expect(context.Objects.TestUser.id).toBe('user-1');
|
||||
});
|
||||
|
||||
it('handles non-existent Objects', () => {
|
||||
const context = ExpressionEvaluator.createNoodlContext();
|
||||
expect(context.Objects.NonExistent).toBeUndefined();
|
||||
});
|
||||
|
||||
it('handles empty Variables', () => {
|
||||
const context = ExpressionEvaluator.createNoodlContext();
|
||||
expect(context.Variables).toBeDefined();
|
||||
expect(typeof context.Variables).toBe('object');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getExpressionVersion', () => {
|
||||
it('returns a number', () => {
|
||||
const version = ExpressionEvaluator.getExpressionVersion();
|
||||
expect(typeof version).toBe('number');
|
||||
});
|
||||
|
||||
it('returns consistent version', () => {
|
||||
const v1 = ExpressionEvaluator.getExpressionVersion();
|
||||
const v2 = ExpressionEvaluator.getExpressionVersion();
|
||||
expect(v1).toBe(v2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearCache', () => {
|
||||
it('clears compiled functions cache', () => {
|
||||
const fn1 = ExpressionEvaluator.compileExpression('1 + 1');
|
||||
ExpressionEvaluator.clearCache();
|
||||
const fn2 = ExpressionEvaluator.compileExpression('1 + 1');
|
||||
expect(fn1).not.toBe(fn2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration tests', () => {
|
||||
it('full workflow: compile, evaluate, subscribe', (done) => {
|
||||
const varsModel = Model.get('--ndl--global-variables');
|
||||
varsModel.set('counter', 0);
|
||||
|
||||
const expression = 'Variables.counter * 2';
|
||||
const deps = ExpressionEvaluator.detectDependencies(expression);
|
||||
const compiled = ExpressionEvaluator.compileExpression(expression);
|
||||
|
||||
let result = ExpressionEvaluator.evaluateExpression(compiled);
|
||||
expect(result).toBe(0);
|
||||
|
||||
const unsub = ExpressionEvaluator.subscribeToChanges(deps, () => {
|
||||
result = ExpressionEvaluator.evaluateExpression(compiled);
|
||||
expect(result).toBe(10);
|
||||
unsub();
|
||||
done();
|
||||
});
|
||||
|
||||
varsModel.set('counter', 5);
|
||||
});
|
||||
|
||||
it('complex expression with multiple operations', () => {
|
||||
const varsModel = Model.get('--ndl--global-variables');
|
||||
varsModel.set('a', 10);
|
||||
varsModel.set('b', 5);
|
||||
|
||||
const expression = 'min(Variables.a, Variables.b) + max(Variables.a, Variables.b)';
|
||||
const compiled = ExpressionEvaluator.compileExpression(expression);
|
||||
const result = ExpressionEvaluator.evaluateExpression(compiled);
|
||||
|
||||
expect(result).toBe(15); // min(10, 5) + max(10, 5) = 5 + 10
|
||||
});
|
||||
});
|
||||
});
|
||||
211
packages/noodl-runtime/test/expression-type-coercion.test.js
Normal file
211
packages/noodl-runtime/test/expression-type-coercion.test.js
Normal file
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Type Coercion Tests for Expression Parameters
|
||||
*
|
||||
* Tests type conversion from expression results to expected property types
|
||||
*/
|
||||
|
||||
const { coerceToType } = require('../src/expression-type-coercion');
|
||||
|
||||
describe('Expression Type Coercion', () => {
|
||||
describe('String coercion', () => {
|
||||
it('converts number to string', () => {
|
||||
expect(coerceToType(42, 'string')).toBe('42');
|
||||
});
|
||||
|
||||
it('converts boolean to string', () => {
|
||||
expect(coerceToType(true, 'string')).toBe('true');
|
||||
expect(coerceToType(false, 'string')).toBe('false');
|
||||
});
|
||||
|
||||
it('converts object to string', () => {
|
||||
expect(coerceToType({ a: 1 }, 'string')).toBe('[object Object]');
|
||||
});
|
||||
|
||||
it('converts array to string', () => {
|
||||
expect(coerceToType([1, 2, 3], 'string')).toBe('1,2,3');
|
||||
});
|
||||
|
||||
it('returns empty string for undefined', () => {
|
||||
expect(coerceToType(undefined, 'string', 'fallback')).toBe('fallback');
|
||||
});
|
||||
|
||||
it('returns empty string for null', () => {
|
||||
expect(coerceToType(null, 'string', 'fallback')).toBe('fallback');
|
||||
});
|
||||
|
||||
it('keeps string as-is', () => {
|
||||
expect(coerceToType('hello', 'string')).toBe('hello');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Number coercion', () => {
|
||||
it('converts string number to number', () => {
|
||||
expect(coerceToType('42', 'number')).toBe(42);
|
||||
});
|
||||
|
||||
it('converts string float to number', () => {
|
||||
expect(coerceToType('3.14', 'number')).toBe(3.14);
|
||||
});
|
||||
|
||||
it('converts boolean to number', () => {
|
||||
expect(coerceToType(true, 'number')).toBe(1);
|
||||
expect(coerceToType(false, 'number')).toBe(0);
|
||||
});
|
||||
|
||||
it('returns fallback for invalid string', () => {
|
||||
expect(coerceToType('not a number', 'number', 0)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns fallback for undefined', () => {
|
||||
expect(coerceToType(undefined, 'number', 42)).toBe(42);
|
||||
});
|
||||
|
||||
it('returns fallback for null', () => {
|
||||
expect(coerceToType(null, 'number', 42)).toBe(42);
|
||||
});
|
||||
|
||||
it('returns fallback for NaN', () => {
|
||||
expect(coerceToType(NaN, 'number', 0)).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps number as-is', () => {
|
||||
expect(coerceToType(123, 'number')).toBe(123);
|
||||
});
|
||||
|
||||
it('converts negative numbers correctly', () => {
|
||||
expect(coerceToType('-10', 'number')).toBe(-10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Boolean coercion', () => {
|
||||
it('converts truthy values to true', () => {
|
||||
expect(coerceToType(1, 'boolean')).toBe(true);
|
||||
expect(coerceToType('yes', 'boolean')).toBe(true);
|
||||
expect(coerceToType({}, 'boolean')).toBe(true);
|
||||
expect(coerceToType([], 'boolean')).toBe(true);
|
||||
});
|
||||
|
||||
it('converts falsy values to false', () => {
|
||||
expect(coerceToType(0, 'boolean')).toBe(false);
|
||||
expect(coerceToType('', 'boolean')).toBe(false);
|
||||
expect(coerceToType(null, 'boolean')).toBe(false);
|
||||
expect(coerceToType(undefined, 'boolean')).toBe(false);
|
||||
expect(coerceToType(NaN, 'boolean')).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps boolean as-is', () => {
|
||||
expect(coerceToType(true, 'boolean')).toBe(true);
|
||||
expect(coerceToType(false, 'boolean')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Color coercion', () => {
|
||||
it('accepts valid hex colors', () => {
|
||||
expect(coerceToType('#ff0000', 'color')).toBe('#ff0000');
|
||||
expect(coerceToType('#FF0000', 'color')).toBe('#FF0000');
|
||||
expect(coerceToType('#abc123', 'color')).toBe('#abc123');
|
||||
});
|
||||
|
||||
it('accepts 3-digit hex colors', () => {
|
||||
expect(coerceToType('#f00', 'color')).toBe('#f00');
|
||||
expect(coerceToType('#FFF', 'color')).toBe('#FFF');
|
||||
});
|
||||
|
||||
it('accepts rgb() format', () => {
|
||||
expect(coerceToType('rgb(255, 0, 0)', 'color')).toBe('rgb(255, 0, 0)');
|
||||
});
|
||||
|
||||
it('accepts rgba() format', () => {
|
||||
expect(coerceToType('rgba(255, 0, 0, 0.5)', 'color')).toBe('rgba(255, 0, 0, 0.5)');
|
||||
});
|
||||
|
||||
it('returns fallback for invalid hex', () => {
|
||||
expect(coerceToType('#gg0000', 'color', '#000000')).toBe('#000000');
|
||||
expect(coerceToType('not a color', 'color', '#000000')).toBe('#000000');
|
||||
});
|
||||
|
||||
it('returns fallback for undefined', () => {
|
||||
expect(coerceToType(undefined, 'color', '#ffffff')).toBe('#ffffff');
|
||||
});
|
||||
|
||||
it('returns fallback for null', () => {
|
||||
expect(coerceToType(null, 'color', '#ffffff')).toBe('#ffffff');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Enum coercion', () => {
|
||||
const enumOptions = ['small', 'medium', 'large'];
|
||||
const enumOptionsWithValues = [
|
||||
{ value: 'sm', label: 'Small' },
|
||||
{ value: 'md', label: 'Medium' },
|
||||
{ value: 'lg', label: 'Large' }
|
||||
];
|
||||
|
||||
it('accepts valid enum value', () => {
|
||||
expect(coerceToType('medium', 'enum', 'small', enumOptions)).toBe('medium');
|
||||
});
|
||||
|
||||
it('accepts valid enum value from object options', () => {
|
||||
expect(coerceToType('md', 'enum', 'sm', enumOptionsWithValues)).toBe('md');
|
||||
});
|
||||
|
||||
it('returns fallback for invalid enum value', () => {
|
||||
expect(coerceToType('xlarge', 'enum', 'small', enumOptions)).toBe('small');
|
||||
});
|
||||
|
||||
it('returns fallback for undefined', () => {
|
||||
expect(coerceToType(undefined, 'enum', 'medium', enumOptions)).toBe('medium');
|
||||
});
|
||||
|
||||
it('returns fallback for null', () => {
|
||||
expect(coerceToType(null, 'enum', 'medium', enumOptions)).toBe('medium');
|
||||
});
|
||||
|
||||
it('converts number to string for enum matching', () => {
|
||||
const numericEnum = ['1', '2', '3'];
|
||||
expect(coerceToType(2, 'enum', '1', numericEnum)).toBe('2');
|
||||
});
|
||||
|
||||
it('returns fallback when enumOptions is not provided', () => {
|
||||
expect(coerceToType('value', 'enum', 'fallback')).toBe('fallback');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Unknown type (passthrough)', () => {
|
||||
it('returns value as-is for unknown types', () => {
|
||||
expect(coerceToType({ a: 1 }, 'object')).toEqual({ a: 1 });
|
||||
expect(coerceToType([1, 2, 3], 'array')).toEqual([1, 2, 3]);
|
||||
expect(coerceToType('test', 'custom')).toBe('test');
|
||||
});
|
||||
|
||||
it('returns undefined for undefined value with unknown type', () => {
|
||||
expect(coerceToType(undefined, 'custom', 'fallback')).toBe('fallback');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('handles empty string as value', () => {
|
||||
expect(coerceToType('', 'string')).toBe('');
|
||||
expect(coerceToType('', 'number', 0)).toBe(0);
|
||||
expect(coerceToType('', 'boolean')).toBe(false);
|
||||
});
|
||||
|
||||
it('handles zero as value', () => {
|
||||
expect(coerceToType(0, 'string')).toBe('0');
|
||||
expect(coerceToType(0, 'number')).toBe(0);
|
||||
expect(coerceToType(0, 'boolean')).toBe(false);
|
||||
});
|
||||
|
||||
it('handles Infinity', () => {
|
||||
expect(coerceToType(Infinity, 'string')).toBe('Infinity');
|
||||
expect(coerceToType(Infinity, 'number')).toBe(Infinity);
|
||||
expect(coerceToType(Infinity, 'boolean')).toBe(true);
|
||||
});
|
||||
|
||||
it('handles negative zero', () => {
|
||||
expect(coerceToType(-0, 'string')).toBe('0');
|
||||
expect(coerceToType(-0, 'number')).toBe(-0);
|
||||
expect(coerceToType(-0, 'boolean')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
345
packages/noodl-runtime/test/node-expression-evaluation.test.js
Normal file
345
packages/noodl-runtime/test/node-expression-evaluation.test.js
Normal file
@@ -0,0 +1,345 @@
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user