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