new code editor

This commit is contained in:
Richard Osborne
2026-01-11 09:48:20 +01:00
parent 7fc49ae3a8
commit 6f08163590
63 changed files with 12074 additions and 74 deletions

View File

@@ -0,0 +1,314 @@
/**
* Expression Evaluator
*
* Compiles JavaScript expressions with access to Noodl globals
* and tracks dependencies for reactive updates.
*
* Features:
* - Full Noodl.Variables, Noodl.Objects, Noodl.Arrays access
* - Math helpers (min, max, cos, sin, etc.)
* - Dependency detection and change subscription
* - Expression versioning for future compatibility
* - Caching of compiled functions
*
* @module expression-evaluator
* @since 1.0.0
*/
'use strict';
const Model = require('./model');
// Expression system version - increment when context changes
const EXPRESSION_VERSION = 1;
// Cache for compiled functions
const compiledFunctionsCache = new Map();
// Math helpers to inject into expression context
const mathHelpers = {
min: Math.min,
max: Math.max,
cos: Math.cos,
sin: Math.sin,
tan: Math.tan,
sqrt: Math.sqrt,
pi: Math.PI,
round: Math.round,
floor: Math.floor,
ceil: Math.ceil,
abs: Math.abs,
random: Math.random,
pow: Math.pow,
log: Math.log,
exp: Math.exp
};
/**
* Detect dependencies in an expression string
* Returns { variables: string[], objects: string[], arrays: string[] }
*
* @param {string} expression - The JavaScript expression to analyze
* @returns {{ variables: string[], objects: string[], arrays: string[] }}
*
* @example
* detectDependencies('Noodl.Variables.isLoggedIn ? "Hi" : "Login"')
* // Returns: { variables: ['isLoggedIn'], objects: [], arrays: [] }
*/
function detectDependencies(expression) {
const dependencies = {
variables: [],
objects: [],
arrays: []
};
// Remove strings to avoid false matches
const exprWithoutStrings = expression
.replace(/"([^"\\]|\\.)*"/g, '""')
.replace(/'([^'\\]|\\.)*'/g, "''")
.replace(/`([^`\\]|\\.)*`/g, '``');
// Match Noodl.Variables.X or Noodl.Variables["X"] or Variables.X or Variables["X"]
const variableMatches = exprWithoutStrings.matchAll(
/(?:Noodl\.)?Variables\.([a-zA-Z_$][a-zA-Z0-9_$]*)|(?:Noodl\.)?Variables\[["']([^"']+)["']\]/g
);
for (const match of variableMatches) {
const varName = match[1] || match[2];
if (varName && !dependencies.variables.includes(varName)) {
dependencies.variables.push(varName);
}
}
// Match Noodl.Objects.X or Noodl.Objects["X"] or Objects.X or Objects["X"]
const objectMatches = exprWithoutStrings.matchAll(
/(?:Noodl\.)?Objects\.([a-zA-Z_$][a-zA-Z0-9_$]*)|(?:Noodl\.)?Objects\[["']([^"']+)["']\]/g
);
for (const match of objectMatches) {
const objId = match[1] || match[2];
if (objId && !dependencies.objects.includes(objId)) {
dependencies.objects.push(objId);
}
}
// Match Noodl.Arrays.X or Noodl.Arrays["X"] or Arrays.X or Arrays["X"]
const arrayMatches = exprWithoutStrings.matchAll(
/(?:Noodl\.)?Arrays\.([a-zA-Z_$][a-zA-Z0-9_$]*)|(?:Noodl\.)?Arrays\[["']([^"']+)["']\]/g
);
for (const match of arrayMatches) {
const arrId = match[1] || match[2];
if (arrId && !dependencies.arrays.includes(arrId)) {
dependencies.arrays.push(arrId);
}
}
return dependencies;
}
/**
* Create the Noodl context object for expression evaluation
*
* @param {Model.Scope} [modelScope] - Optional model scope (defaults to global Model)
* @returns {Object} Noodl context with Variables, Objects, Arrays accessors
*/
function createNoodlContext(modelScope) {
const scope = modelScope || Model;
// Get the global variables model
const variablesModel = scope.get('--ndl--global-variables');
return {
Variables: variablesModel ? variablesModel.data : {},
Objects: new Proxy(
{},
{
get(target, prop) {
if (typeof prop === 'symbol') return undefined;
const obj = scope.get(prop);
return obj ? obj.data : undefined;
}
}
),
Arrays: new Proxy(
{},
{
get(target, prop) {
if (typeof prop === 'symbol') return undefined;
const arr = scope.get(prop);
return arr ? arr.data : undefined;
}
}
),
Object: scope
};
}
/**
* Compile an expression string into a callable function
*
* @param {string} expression - The JavaScript expression to compile
* @returns {Function|null} Compiled function or null if compilation fails
*
* @example
* const fn = compileExpression('min(10, 5) + 2');
* const result = evaluateExpression(fn); // 7
*/
function compileExpression(expression) {
const cacheKey = `v${EXPRESSION_VERSION}:${expression}`;
if (compiledFunctionsCache.has(cacheKey)) {
return compiledFunctionsCache.get(cacheKey);
}
// Build parameter list for the function
const paramNames = ['Noodl', 'Variables', 'Objects', 'Arrays', ...Object.keys(mathHelpers)];
// Wrap expression in return statement with error handling
const functionBody = `
"use strict";
try {
return (${expression});
} catch (e) {
console.error('Expression evaluation error:', e.message);
return undefined;
}
`;
try {
const fn = new Function(...paramNames, functionBody);
compiledFunctionsCache.set(cacheKey, fn);
return fn;
} catch (e) {
console.error('Expression compilation error:', e.message);
return null;
}
}
/**
* Evaluate a compiled expression with the current context
*
* @param {Function|null} compiledFn - The compiled expression function
* @param {Model.Scope} [modelScope] - Optional model scope
* @returns {*} The result of the expression evaluation
*/
function evaluateExpression(compiledFn, modelScope) {
if (!compiledFn) return undefined;
const noodlContext = createNoodlContext(modelScope);
const mathValues = Object.values(mathHelpers);
try {
// Pass Noodl context plus shorthand accessors
return compiledFn(noodlContext, noodlContext.Variables, noodlContext.Objects, noodlContext.Arrays, ...mathValues);
} catch (e) {
console.error('Expression evaluation error:', e.message);
return undefined;
}
}
/**
* Subscribe to changes in expression dependencies
* Returns an unsubscribe function
*
* @param {{ variables: string[], objects: string[], arrays: string[] }} dependencies
* @param {Function} callback - Called when any dependency changes
* @param {Model.Scope} [modelScope] - Optional model scope
* @returns {Function} Unsubscribe function
*
* @example
* const deps = { variables: ['userName'], objects: [], arrays: [] };
* const unsub = subscribeToChanges(deps, () => console.log('Changed!'));
* // Later: unsub();
*/
function subscribeToChanges(dependencies, callback, modelScope) {
const scope = modelScope || Model;
const listeners = [];
// Subscribe to variable changes
if (dependencies.variables.length > 0) {
const variablesModel = scope.get('--ndl--global-variables');
if (variablesModel) {
const handler = (args) => {
// Check if any of our dependencies changed
if (dependencies.variables.some((v) => args.name === v || !args.name)) {
callback();
}
};
variablesModel.on('change', handler);
listeners.push(() => variablesModel.off('change', handler));
}
}
// Subscribe to object changes
for (const objId of dependencies.objects) {
const objModel = scope.get(objId);
if (objModel) {
const handler = () => callback();
objModel.on('change', handler);
listeners.push(() => objModel.off('change', handler));
}
}
// Subscribe to array changes
for (const arrId of dependencies.arrays) {
const arrModel = scope.get(arrId);
if (arrModel) {
const handler = () => callback();
arrModel.on('change', handler);
listeners.push(() => arrModel.off('change', handler));
}
}
// Return unsubscribe function
return () => {
listeners.forEach((unsub) => unsub());
};
}
/**
* Validate expression syntax without executing
*
* @param {string} expression - The expression to validate
* @returns {{ valid: boolean, error: string|null }}
*
* @example
* validateExpression('1 + 1'); // { valid: true, error: null }
* validateExpression('1 +'); // { valid: false, error: 'Unexpected end of input' }
*/
function validateExpression(expression) {
try {
new Function(`return (${expression})`);
return { valid: true, error: null };
} catch (e) {
return { valid: false, error: e.message };
}
}
/**
* Get the current expression system version
* Used for migration when expression context changes
*
* @returns {number} Current version number
*/
function getExpressionVersion() {
return EXPRESSION_VERSION;
}
/**
* Clear the compiled functions cache
* Useful for testing or when context changes
*/
function clearCache() {
compiledFunctionsCache.clear();
}
module.exports = {
detectDependencies,
compileExpression,
evaluateExpression,
subscribeToChanges,
validateExpression,
createNoodlContext,
getExpressionVersion,
clearCache,
EXPRESSION_VERSION
};

View File

@@ -0,0 +1,111 @@
/**
* Expression Type Coercion
*
* Coerces expression evaluation results to match expected property types.
* Ensures type safety when expressions are used for node properties.
*
* @module expression-type-coercion
* @since 1.1.0
*/
'use strict';
/**
* Coerce expression result to expected property type
*
* @param {*} value - The value from expression evaluation
* @param {string} expectedType - The expected type (string, number, boolean, color, enum, etc.)
* @param {*} [fallback] - Fallback value if coercion fails
* @param {Array} [enumOptions] - Valid options for enum type
* @returns {*} Coerced value or fallback
*
* @example
* coerceToType('42', 'number') // 42
* coerceToType(true, 'string') // 'true'
* coerceToType('#ff0000', 'color') // '#ff0000'
*/
function coerceToType(value, expectedType, fallback, enumOptions) {
// Handle undefined/null upfront
if (value === undefined || value === null) {
return fallback;
}
switch (expectedType) {
case 'string':
return String(value);
case 'number': {
const num = Number(value);
// Check for NaN (includes invalid strings, NaN itself, etc.)
return isNaN(num) ? fallback : num;
}
case 'boolean':
return !!value;
case 'color':
return coerceToColor(value, fallback);
case 'enum':
return coerceToEnum(value, fallback, enumOptions);
default:
// Unknown types pass through as-is
return value;
}
}
/**
* Coerce value to valid color string
*
* @param {*} value - The value to coerce
* @param {*} fallback - Fallback color
* @returns {string} Valid color or fallback
*/
function coerceToColor(value, fallback) {
const str = String(value);
// Validate hex colors: #RGB or #RRGGBB (case insensitive)
if (/^#[0-9A-Fa-f]{3}$/.test(str) || /^#[0-9A-Fa-f]{6}$/.test(str)) {
return str;
}
// Validate rgb() or rgba() format
if (/^rgba?\(/.test(str)) {
return str;
}
// Invalid color format
return fallback;
}
/**
* Coerce value to valid enum option
*
* @param {*} value - The value to coerce
* @param {*} fallback - Fallback enum value
* @param {Array} enumOptions - Valid enum options (strings or {value, label} objects)
* @returns {string} Valid enum value or fallback
*/
function coerceToEnum(value, fallback, enumOptions) {
if (!enumOptions) {
return fallback;
}
const enumVal = String(value);
// Check if value matches any option
const isValid = enumOptions.some((opt) => {
if (typeof opt === 'string') {
return opt === enumVal;
}
// Handle {value, label} format
return opt.value === enumVal;
});
return isValid ? enumVal : fallback;
}
module.exports = {
coerceToType
};

View File

@@ -1,4 +1,21 @@
const OutputProperty = require('./outputproperty');
const { evaluateExpression } = require('./expression-evaluator');
const { coerceToType } = require('./expression-type-coercion');
/**
* Helper to check if a value is an expression parameter
* @param {*} value - The value to check
* @returns {boolean} True if value is an expression parameter
*/
function isExpressionParameter(value) {
return (
value !== null &&
value !== undefined &&
typeof value === 'object' &&
value.mode === 'expression' &&
typeof value.expression === 'string'
);
}
/**
* Base class for all Nodes
@@ -83,6 +100,63 @@ Node.prototype.registerInputIfNeeded = function () {
//noop, can be overriden by subclasses
};
/**
* Evaluate an expression parameter and return the coerced result
*
* @param {*} paramValue - The parameter value (might be an ExpressionParameter)
* @param {string} portName - The input port name
* @returns {*} The evaluated and coerced value (or original if not an expression)
*/
Node.prototype._evaluateExpressionParameter = function (paramValue, portName) {
// Check if this is an expression parameter
if (!isExpressionParameter(paramValue)) {
return paramValue; // Simple value, return as-is
}
const input = this.getInput(portName);
if (!input) {
return paramValue.fallback; // No input definition, use fallback
}
try {
// Evaluate the expression with access to context
const result = evaluateExpression(paramValue.expression, this.context);
// Coerce to expected type
const coercedValue = coerceToType(result, input.type, paramValue.fallback);
// Clear any previous expression errors
if (this.context.editorConnection) {
this.context.editorConnection.clearWarning(
this.nodeScope.componentOwner.name,
this.id,
'expression-error-' + portName
);
}
return coercedValue;
} catch (error) {
// Expression evaluation failed
console.warn(`Expression evaluation failed for ${this.name}.${portName}:`, error);
// Show warning in editor
if (this.context.editorConnection) {
this.context.editorConnection.sendWarning(
this.nodeScope.componentOwner.name,
this.id,
'expression-error-' + portName,
{
showGlobally: true,
message: `Expression error: ${error.message}`
}
);
}
// Return fallback value
return paramValue.fallback;
}
};
Node.prototype.setInputValue = function (name, value) {
// DEBUG: Track input value setting for HTTP node
if (this.name === 'net.noodl.HTTP') {
@@ -115,6 +189,9 @@ Node.prototype.setInputValue = function (name, value) {
//Save the current input value. Save it before resolving color styles so delta updates on color styles work correctly
this._inputValues[name] = value;
// Evaluate expression parameters before further processing
value = this._evaluateExpressionParameter(value, name);
if (input.type === 'color' && this.context && this.context.styles) {
value = this.context.styles.resolveColor(value);
} else if (input.type === 'array' && typeof value === 'string') {

View File

@@ -1,8 +1,7 @@
'use strict';
const difference = require('lodash.difference');
//const Model = require('./data/model');
const ExpressionEvaluator = require('../../expression-evaluator');
const ExpressionNode = {
name: 'Expression',
@@ -26,6 +25,19 @@ const ExpressionNode = {
internal.compiledFunction = undefined;
internal.inputNames = [];
internal.inputValues = [];
// New: Expression evaluator integration
internal.noodlDependencies = { variables: [], objects: [], arrays: [] };
internal.unsubscribe = null;
},
methods: {
_onNodeDeleted: function () {
// Clean up reactive subscriptions to prevent memory leaks
if (this._internal.unsubscribe) {
this._internal.unsubscribe();
this._internal.unsubscribe = null;
}
}
},
getInspectInfo() {
return this._internal.cachedValue;
@@ -72,15 +84,31 @@ const ExpressionNode = {
self._inputValues[name] = 0;
});
/* if(value.indexOf('Vars') !== -1 || value.indexOf('Variables') !== -1) {
// This expression is using variables, it should listen for changes
this._internal.onVariablesChangedCallback = (args) => {
this._scheduleEvaluateExpression()
}
// Detect dependencies for reactive updates
internal.noodlDependencies = ExpressionEvaluator.detectDependencies(value);
Model.get('--ndl--global-variables').off('change',this._internal.onVariablesChangedCallback)
Model.get('--ndl--global-variables').on('change',this._internal.onVariablesChangedCallback)
}*/
// Clean up old subscription
if (internal.unsubscribe) {
internal.unsubscribe();
internal.unsubscribe = null;
}
// Subscribe to Noodl global changes if expression uses them
if (
internal.noodlDependencies.variables.length > 0 ||
internal.noodlDependencies.objects.length > 0 ||
internal.noodlDependencies.arrays.length > 0
) {
internal.unsubscribe = ExpressionEvaluator.subscribeToChanges(
internal.noodlDependencies,
function () {
if (!self.isInputConnected('run')) {
self._scheduleEvaluateExpression();
}
},
self.context && self.context.modelScope
);
}
internal.inputNames = Object.keys(internal.scope);
if (!this.isInputConnected('run')) this._scheduleEvaluateExpression();
@@ -141,6 +169,33 @@ const ExpressionNode = {
group: 'Events',
type: 'signal',
displayName: 'On False'
},
// New typed outputs for better downstream compatibility
asString: {
group: 'Typed Results',
type: 'string',
displayName: 'As String',
getter: function () {
const val = this._internal.cachedValue;
return val !== undefined && val !== null ? String(val) : '';
}
},
asNumber: {
group: 'Typed Results',
type: 'number',
displayName: 'As Number',
getter: function () {
const val = this._internal.cachedValue;
return typeof val === 'number' ? val : Number(val) || 0;
}
},
asBoolean: {
group: 'Typed Results',
type: 'boolean',
displayName: 'As Boolean',
getter: function () {
return !!this._internal.cachedValue;
}
}
},
prototypeExtensions: {
@@ -235,8 +290,19 @@ var functionPreamble = [
' floor = Math.floor,' +
' ceil = Math.ceil,' +
' abs = Math.abs,' +
' random = Math.random;'
/* ' Vars = Variables = Noodl.Object.get("--ndl--global-variables");' */
' random = Math.random,' +
' pow = Math.pow,' +
' log = Math.log,' +
' exp = Math.exp;' +
// Add Noodl global context
'try {' +
' var NoodlContext = (typeof Noodl !== "undefined") ? Noodl : (typeof global !== "undefined" && global.Noodl) || {};' +
' var Variables = NoodlContext.Variables || {};' +
' var Objects = NoodlContext.Objects || {};' +
' var Arrays = NoodlContext.Arrays || {};' +
'} catch (e) {' +
' var Variables = {}, Objects = {}, Arrays = {};' +
'}'
].join('');
//Since apply cannot be used on constructors (i.e. new Something) we need this hax
@@ -264,11 +330,19 @@ var portsToIgnore = [
'ceil',
'abs',
'random',
'pow',
'log',
'exp',
'Math',
'window',
'document',
'undefined',
'Vars',
'Variables',
'Objects',
'Arrays',
'Noodl',
'NoodlContext',
'true',
'false',
'null',
@@ -326,13 +400,43 @@ function updatePorts(nodeId, expression, editorConnection) {
}
function evalCompileWarnings(editorConnection, node) {
try {
new Function(node.parameters.expression);
const expression = node.parameters.expression;
if (!expression) {
editorConnection.clearWarning(node.component.name, node.id, 'expression-compile-error');
} catch (e) {
return;
}
// Validate expression syntax
const validation = ExpressionEvaluator.validateExpression(expression);
if (!validation.valid) {
editorConnection.sendWarning(node.component.name, node.id, 'expression-compile-error', {
message: e.message
message: 'Syntax error: ' + validation.error
});
} else {
editorConnection.clearWarning(node.component.name, node.id, 'expression-compile-error');
// Optionally show detected dependencies as info (helpful for users)
const deps = ExpressionEvaluator.detectDependencies(expression);
const depCount = deps.variables.length + deps.objects.length + deps.arrays.length;
if (depCount > 0) {
const depList = [];
if (deps.variables.length > 0) {
depList.push('Variables: ' + deps.variables.join(', '));
}
if (deps.objects.length > 0) {
depList.push('Objects: ' + deps.objects.join(', '));
}
if (deps.arrays.length > 0) {
depList.push('Arrays: ' + deps.arrays.join(', '));
}
// This is just informational, not an error
// Could be shown in a future info panel
// For now, we'll just log it
console.log('[Expression Node] Reactive dependencies detected:', depList.join('; '));
}
}
}

View 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
});
});
});

View 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);
});
});
});

View 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();
});
});
});
});