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,279 @@
/**
* Expression Parameter Types Tests
*
* Tests type definitions and helper functions for expression-based parameters
*/
import {
ExpressionParameter,
isExpressionParameter,
getParameterDisplayValue,
getParameterActualValue,
createExpressionParameter,
toParameter
} from '../../src/editor/src/models/ExpressionParameter';
describe('Expression Parameter Types', () => {
describe('isExpressionParameter', () => {
it('identifies expression parameters', () => {
const expr: ExpressionParameter = {
mode: 'expression',
expression: 'Variables.x + 1',
fallback: 0
};
expect(isExpressionParameter(expr)).toBe(true);
});
it('identifies expression without fallback', () => {
const expr: ExpressionParameter = {
mode: 'expression',
expression: 'Variables.x'
};
expect(isExpressionParameter(expr)).toBe(true);
});
it('rejects simple values', () => {
expect(isExpressionParameter(42)).toBe(false);
expect(isExpressionParameter('hello')).toBe(false);
expect(isExpressionParameter(true)).toBe(false);
expect(isExpressionParameter(null)).toBe(false);
expect(isExpressionParameter(undefined)).toBe(false);
});
it('rejects objects without mode', () => {
expect(isExpressionParameter({ expression: 'test' })).toBe(false);
});
it('rejects objects with wrong mode', () => {
expect(isExpressionParameter({ mode: 'fixed', value: 42 })).toBe(false);
});
it('rejects objects without expression', () => {
expect(isExpressionParameter({ mode: 'expression' })).toBe(false);
});
it('rejects objects with non-string expression', () => {
expect(isExpressionParameter({ mode: 'expression', expression: 42 })).toBe(false);
});
});
describe('getParameterDisplayValue', () => {
it('returns expression string for expression parameters', () => {
const expr: ExpressionParameter = {
mode: 'expression',
expression: 'Variables.x * 2',
fallback: 0
};
expect(getParameterDisplayValue(expr)).toBe('Variables.x * 2');
});
it('returns expression even without fallback', () => {
const expr: ExpressionParameter = {
mode: 'expression',
expression: 'Variables.count'
};
expect(getParameterDisplayValue(expr)).toBe('Variables.count');
});
it('returns value as-is for simple values', () => {
expect(getParameterDisplayValue(42)).toBe(42);
expect(getParameterDisplayValue('hello')).toBe('hello');
expect(getParameterDisplayValue(true)).toBe(true);
expect(getParameterDisplayValue(null)).toBe(null);
expect(getParameterDisplayValue(undefined)).toBe(undefined);
});
it('returns value as-is for objects', () => {
const obj = { a: 1, b: 2 };
expect(getParameterDisplayValue(obj)).toBe(obj);
});
});
describe('getParameterActualValue', () => {
it('returns fallback for expression parameters', () => {
const expr: ExpressionParameter = {
mode: 'expression',
expression: 'Variables.x * 2',
fallback: 100
};
expect(getParameterActualValue(expr)).toBe(100);
});
it('returns undefined for expression without fallback', () => {
const expr: ExpressionParameter = {
mode: 'expression',
expression: 'Variables.x'
};
expect(getParameterActualValue(expr)).toBeUndefined();
});
it('returns value as-is for simple values', () => {
expect(getParameterActualValue(42)).toBe(42);
expect(getParameterActualValue('hello')).toBe('hello');
expect(getParameterActualValue(false)).toBe(false);
});
});
describe('createExpressionParameter', () => {
it('creates expression parameter with all fields', () => {
const expr = createExpressionParameter('Variables.count', 0, 2);
expect(expr.mode).toBe('expression');
expect(expr.expression).toBe('Variables.count');
expect(expr.fallback).toBe(0);
expect(expr.version).toBe(2);
});
it('uses default version if not provided', () => {
const expr = createExpressionParameter('Variables.x', 10);
expect(expr.version).toBe(1);
});
it('allows undefined fallback', () => {
const expr = createExpressionParameter('Variables.x');
expect(expr.fallback).toBeUndefined();
expect(expr.version).toBe(1);
});
it('allows null fallback', () => {
const expr = createExpressionParameter('Variables.x', null);
expect(expr.fallback).toBe(null);
});
it('allows zero as fallback', () => {
const expr = createExpressionParameter('Variables.x', 0);
expect(expr.fallback).toBe(0);
});
it('allows empty string as fallback', () => {
const expr = createExpressionParameter('Variables.x', '');
expect(expr.fallback).toBe('');
});
});
describe('toParameter', () => {
it('passes through expression parameters', () => {
const expr: ExpressionParameter = {
mode: 'expression',
expression: 'Variables.x',
fallback: 0
};
expect(toParameter(expr)).toBe(expr);
});
it('returns simple values as-is', () => {
expect(toParameter(42)).toBe(42);
expect(toParameter('hello')).toBe('hello');
expect(toParameter(true)).toBe(true);
expect(toParameter(null)).toBe(null);
expect(toParameter(undefined)).toBe(undefined);
});
it('returns objects as-is', () => {
const obj = { a: 1 };
expect(toParameter(obj)).toBe(obj);
});
});
describe('Serialization', () => {
it('expression parameters serialize to JSON correctly', () => {
const expr = createExpressionParameter('Variables.count', 10);
const json = JSON.stringify(expr);
const parsed = JSON.parse(json);
expect(parsed.mode).toBe('expression');
expect(parsed.expression).toBe('Variables.count');
expect(parsed.fallback).toBe(10);
expect(parsed.version).toBe(1);
});
it('deserialized expression parameters are recognized', () => {
const json = '{"mode":"expression","expression":"Variables.x","fallback":0,"version":1}';
const parsed = JSON.parse(json);
expect(isExpressionParameter(parsed)).toBe(true);
expect(parsed.expression).toBe('Variables.x');
expect(parsed.fallback).toBe(0);
});
it('handles undefined fallback in serialization', () => {
const expr = createExpressionParameter('Variables.x');
const json = JSON.stringify(expr);
const parsed = JSON.parse(json);
expect(parsed.fallback).toBeUndefined();
expect(isExpressionParameter(parsed)).toBe(true);
});
});
describe('Backward Compatibility', () => {
it('simple values in parameters object work', () => {
const params = {
marginLeft: 16,
color: '#ff0000',
enabled: true
};
expect(isExpressionParameter(params.marginLeft)).toBe(false);
expect(isExpressionParameter(params.color)).toBe(false);
expect(isExpressionParameter(params.enabled)).toBe(false);
});
it('mixed parameters work', () => {
const params = {
marginLeft: createExpressionParameter('Variables.spacing', 16),
marginRight: 8, // Simple value
color: '#ff0000'
};
expect(isExpressionParameter(params.marginLeft)).toBe(true);
expect(isExpressionParameter(params.marginRight)).toBe(false);
expect(isExpressionParameter(params.color)).toBe(false);
});
it('old project parameters load correctly', () => {
// Simulating loading old project
const oldParams = {
width: 200,
height: 100,
text: 'Hello'
};
// None should be expressions
Object.values(oldParams).forEach((value) => {
expect(isExpressionParameter(value)).toBe(false);
});
});
it('new project with expressions loads correctly', () => {
const newParams = {
width: createExpressionParameter('Variables.width', 200),
height: 100, // Mixed: some expression, some not
text: 'Static text'
};
expect(isExpressionParameter(newParams.width)).toBe(true);
expect(isExpressionParameter(newParams.height)).toBe(false);
expect(isExpressionParameter(newParams.text)).toBe(false);
});
});
describe('Edge Cases', () => {
it('handles complex expressions', () => {
const expr = createExpressionParameter('Variables.isAdmin ? "Admin Panel" : "User Panel"', 'User Panel');
expect(expr.expression).toBe('Variables.isAdmin ? "Admin Panel" : "User Panel"');
});
it('handles multi-line expressions', () => {
const multiLine = `Variables.items
.filter(x => x.active)
.length`;
const expr = createExpressionParameter(multiLine, 0);
expect(expr.expression).toBe(multiLine);
});
it('handles expressions with special characters', () => {
const expr = createExpressionParameter('Variables["my-variable"]', null);
expect(expr.expression).toBe('Variables["my-variable"]');
});
});
});

View File

@@ -0,0 +1,387 @@
/**
* Unit tests for ParameterValueResolver
*
* Tests the resolution of parameter values from storage (primitives or expression objects)
* to display/runtime values based on context.
*
* @module noodl-editor/tests/utils
*/
import { describe, it, expect } from '@jest/globals';
import { createExpressionParameter, ExpressionParameter } from '../../src/editor/src/models/ExpressionParameter';
import { ParameterValueResolver, ValueContext } from '../../src/editor/src/utils/ParameterValueResolver';
describe('ParameterValueResolver', () => {
describe('resolve()', () => {
describe('with primitive values', () => {
it('should return string values as-is', () => {
expect(ParameterValueResolver.resolve('hello', ValueContext.Display)).toBe('hello');
expect(ParameterValueResolver.resolve('', ValueContext.Display)).toBe('');
expect(ParameterValueResolver.resolve('123', ValueContext.Display)).toBe('123');
});
it('should return number values as-is', () => {
expect(ParameterValueResolver.resolve(42, ValueContext.Display)).toBe(42);
expect(ParameterValueResolver.resolve(0, ValueContext.Display)).toBe(0);
expect(ParameterValueResolver.resolve(-42.5, ValueContext.Display)).toBe(-42.5);
});
it('should return boolean values as-is', () => {
expect(ParameterValueResolver.resolve(true, ValueContext.Display)).toBe(true);
expect(ParameterValueResolver.resolve(false, ValueContext.Display)).toBe(false);
});
it('should return undefined as-is', () => {
expect(ParameterValueResolver.resolve(undefined, ValueContext.Display)).toBe(undefined);
});
it('should handle null', () => {
expect(ParameterValueResolver.resolve(null, ValueContext.Display)).toBe(null);
});
});
describe('with expression parameters', () => {
it('should extract fallback from expression parameter in Display context', () => {
const exprParam = createExpressionParameter('Variables.x', 'default', 1);
expect(ParameterValueResolver.resolve(exprParam, ValueContext.Display)).toBe('default');
});
it('should extract fallback from expression parameter in Runtime context', () => {
const exprParam = createExpressionParameter('Variables.x', 'default', 1);
expect(ParameterValueResolver.resolve(exprParam, ValueContext.Runtime)).toBe('default');
});
it('should return full object in Serialization context', () => {
const exprParam = createExpressionParameter('Variables.x', 'default', 1);
const result = ParameterValueResolver.resolve(exprParam, ValueContext.Serialization);
expect(result).toBe(exprParam);
expect((result as ExpressionParameter).mode).toBe('expression');
});
it('should handle expression parameter with undefined fallback', () => {
const exprParam = createExpressionParameter('Variables.x', undefined, 1);
expect(ParameterValueResolver.resolve(exprParam, ValueContext.Display)).toBe('');
});
it('should handle expression parameter with numeric fallback', () => {
const exprParam = createExpressionParameter('Variables.count', 42, 1);
expect(ParameterValueResolver.resolve(exprParam, ValueContext.Display)).toBe(42);
});
it('should handle expression parameter with boolean fallback', () => {
const exprParam = createExpressionParameter('Variables.flag', true, 1);
expect(ParameterValueResolver.resolve(exprParam, ValueContext.Display)).toBe(true);
});
it('should handle expression parameter with empty string fallback', () => {
const exprParam = createExpressionParameter('Variables.x', '', 1);
expect(ParameterValueResolver.resolve(exprParam, ValueContext.Display)).toBe('');
});
});
describe('edge cases', () => {
it('should handle objects that are not expression parameters', () => {
const regularObj = { foo: 'bar' };
// Should return as-is since it's not an expression parameter
expect(ParameterValueResolver.resolve(regularObj, ValueContext.Display)).toBe(regularObj);
});
it('should default to fallback for unknown context', () => {
const exprParam = createExpressionParameter('Variables.x', 'default', 1);
// Cast to any to test invalid context
expect(ParameterValueResolver.resolve(exprParam, 'invalid' as any)).toBe('default');
});
});
});
describe('toString()', () => {
describe('with primitive values', () => {
it('should convert string to string', () => {
expect(ParameterValueResolver.toString('hello')).toBe('hello');
expect(ParameterValueResolver.toString('')).toBe('');
});
it('should convert number to string', () => {
expect(ParameterValueResolver.toString(42)).toBe('42');
expect(ParameterValueResolver.toString(0)).toBe('0');
expect(ParameterValueResolver.toString(-42.5)).toBe('-42.5');
});
it('should convert boolean to string', () => {
expect(ParameterValueResolver.toString(true)).toBe('true');
expect(ParameterValueResolver.toString(false)).toBe('false');
});
it('should convert undefined to empty string', () => {
expect(ParameterValueResolver.toString(undefined)).toBe('');
});
it('should convert null to empty string', () => {
expect(ParameterValueResolver.toString(null)).toBe('');
});
});
describe('with expression parameters', () => {
it('should extract fallback as string from expression parameter', () => {
const exprParam = createExpressionParameter('Variables.x', 'test', 1);
expect(ParameterValueResolver.toString(exprParam)).toBe('test');
});
it('should convert numeric fallback to string', () => {
const exprParam = createExpressionParameter('Variables.count', 42, 1);
expect(ParameterValueResolver.toString(exprParam)).toBe('42');
});
it('should convert boolean fallback to string', () => {
const exprParam = createExpressionParameter('Variables.flag', true, 1);
expect(ParameterValueResolver.toString(exprParam)).toBe('true');
});
it('should handle expression parameter with undefined fallback', () => {
const exprParam = createExpressionParameter('Variables.x', undefined, 1);
expect(ParameterValueResolver.toString(exprParam)).toBe('');
});
it('should handle expression parameter with null fallback', () => {
const exprParam = createExpressionParameter('Variables.x', null, 1);
expect(ParameterValueResolver.toString(exprParam)).toBe('');
});
});
describe('edge cases', () => {
it('should handle objects that are not expression parameters', () => {
const regularObj = { foo: 'bar' };
// Should return empty string for safety (defensive behavior)
expect(ParameterValueResolver.toString(regularObj)).toBe('');
});
it('should handle arrays', () => {
expect(ParameterValueResolver.toString([1, 2, 3])).toBe('');
});
});
});
describe('toNumber()', () => {
describe('with primitive values', () => {
it('should return number as-is', () => {
expect(ParameterValueResolver.toNumber(42)).toBe(42);
expect(ParameterValueResolver.toNumber(0)).toBe(0);
expect(ParameterValueResolver.toNumber(-42.5)).toBe(-42.5);
});
it('should convert numeric string to number', () => {
expect(ParameterValueResolver.toNumber('42')).toBe(42);
expect(ParameterValueResolver.toNumber('0')).toBe(0);
expect(ParameterValueResolver.toNumber('-42.5')).toBe(-42.5);
});
it('should return undefined for non-numeric string', () => {
expect(ParameterValueResolver.toNumber('hello')).toBe(undefined);
expect(ParameterValueResolver.toNumber('not a number')).toBe(undefined);
});
it('should return undefined for undefined', () => {
expect(ParameterValueResolver.toNumber(undefined)).toBe(undefined);
});
it('should return undefined for null', () => {
expect(ParameterValueResolver.toNumber(null)).toBe(undefined);
});
it('should convert boolean to number', () => {
expect(ParameterValueResolver.toNumber(true)).toBe(1);
expect(ParameterValueResolver.toNumber(false)).toBe(0);
});
});
describe('with expression parameters', () => {
it('should extract numeric fallback from expression parameter', () => {
const exprParam = createExpressionParameter('Variables.count', 42, 1);
expect(ParameterValueResolver.toNumber(exprParam)).toBe(42);
});
it('should convert string fallback to number', () => {
const exprParam = createExpressionParameter('Variables.count', '42', 1);
expect(ParameterValueResolver.toNumber(exprParam)).toBe(42);
});
it('should return undefined for non-numeric fallback', () => {
const exprParam = createExpressionParameter('Variables.text', 'hello', 1);
expect(ParameterValueResolver.toNumber(exprParam)).toBe(undefined);
});
it('should handle expression parameter with undefined fallback', () => {
const exprParam = createExpressionParameter('Variables.x', undefined, 1);
expect(ParameterValueResolver.toNumber(exprParam)).toBe(undefined);
});
it('should handle expression parameter with null fallback', () => {
const exprParam = createExpressionParameter('Variables.x', null, 1);
expect(ParameterValueResolver.toNumber(exprParam)).toBe(undefined);
});
});
describe('edge cases', () => {
it('should handle objects that are not expression parameters', () => {
const regularObj = { foo: 'bar' };
expect(ParameterValueResolver.toNumber(regularObj)).toBe(undefined);
});
it('should handle arrays', () => {
expect(ParameterValueResolver.toNumber([1, 2, 3])).toBe(undefined);
});
it('should handle empty string', () => {
expect(ParameterValueResolver.toNumber('')).toBe(0); // Empty string converts to 0
});
it('should handle whitespace string', () => {
expect(ParameterValueResolver.toNumber(' ')).toBe(0); // Whitespace converts to 0
});
});
});
describe('toBoolean()', () => {
describe('with primitive values', () => {
it('should return boolean as-is', () => {
expect(ParameterValueResolver.toBoolean(true)).toBe(true);
expect(ParameterValueResolver.toBoolean(false)).toBe(false);
});
it('should convert truthy strings to true', () => {
expect(ParameterValueResolver.toBoolean('hello')).toBe(true);
expect(ParameterValueResolver.toBoolean('0')).toBe(true); // Non-empty string is truthy
expect(ParameterValueResolver.toBoolean('false')).toBe(true); // Non-empty string is truthy
});
it('should convert empty string to false', () => {
expect(ParameterValueResolver.toBoolean('')).toBe(false);
});
it('should convert numbers using truthiness', () => {
expect(ParameterValueResolver.toBoolean(1)).toBe(true);
expect(ParameterValueResolver.toBoolean(42)).toBe(true);
expect(ParameterValueResolver.toBoolean(0)).toBe(false);
expect(ParameterValueResolver.toBoolean(-1)).toBe(true);
});
it('should convert undefined to false', () => {
expect(ParameterValueResolver.toBoolean(undefined)).toBe(false);
});
it('should convert null to false', () => {
expect(ParameterValueResolver.toBoolean(null)).toBe(false);
});
});
describe('with expression parameters', () => {
it('should extract boolean fallback from expression parameter', () => {
const exprParam = createExpressionParameter('Variables.flag', true, 1);
expect(ParameterValueResolver.toBoolean(exprParam)).toBe(true);
});
it('should convert string fallback to boolean', () => {
const exprParamTruthy = createExpressionParameter('Variables.text', 'hello', 1);
expect(ParameterValueResolver.toBoolean(exprParamTruthy)).toBe(true);
const exprParamFalsy = createExpressionParameter('Variables.text', '', 1);
expect(ParameterValueResolver.toBoolean(exprParamFalsy)).toBe(false);
});
it('should convert numeric fallback to boolean', () => {
const exprParamTruthy = createExpressionParameter('Variables.count', 42, 1);
expect(ParameterValueResolver.toBoolean(exprParamTruthy)).toBe(true);
const exprParamFalsy = createExpressionParameter('Variables.count', 0, 1);
expect(ParameterValueResolver.toBoolean(exprParamFalsy)).toBe(false);
});
it('should handle expression parameter with undefined fallback', () => {
const exprParam = createExpressionParameter('Variables.x', undefined, 1);
expect(ParameterValueResolver.toBoolean(exprParam)).toBe(false);
});
it('should handle expression parameter with null fallback', () => {
const exprParam = createExpressionParameter('Variables.x', null, 1);
expect(ParameterValueResolver.toBoolean(exprParam)).toBe(false);
});
});
describe('edge cases', () => {
it('should handle objects that are not expression parameters', () => {
const regularObj = { foo: 'bar' };
// Non-expression objects should return false (defensive behavior)
expect(ParameterValueResolver.toBoolean(regularObj)).toBe(false);
});
it('should handle arrays', () => {
expect(ParameterValueResolver.toBoolean([1, 2, 3])).toBe(false);
});
});
});
describe('isExpression()', () => {
it('should return true for expression parameters', () => {
const exprParam = createExpressionParameter('Variables.x', 'default', 1);
expect(ParameterValueResolver.isExpression(exprParam)).toBe(true);
});
it('should return false for primitive values', () => {
expect(ParameterValueResolver.isExpression('hello')).toBe(false);
expect(ParameterValueResolver.isExpression(42)).toBe(false);
expect(ParameterValueResolver.isExpression(true)).toBe(false);
expect(ParameterValueResolver.isExpression(undefined)).toBe(false);
expect(ParameterValueResolver.isExpression(null)).toBe(false);
});
it('should return false for regular objects', () => {
const regularObj = { foo: 'bar' };
expect(ParameterValueResolver.isExpression(regularObj)).toBe(false);
});
it('should return false for arrays', () => {
expect(ParameterValueResolver.isExpression([1, 2, 3])).toBe(false);
});
});
describe('integration scenarios', () => {
it('should handle converting expression parameter through all type conversions', () => {
const exprParam = createExpressionParameter('Variables.count', 42, 1);
expect(ParameterValueResolver.toString(exprParam)).toBe('42');
expect(ParameterValueResolver.toNumber(exprParam)).toBe(42);
expect(ParameterValueResolver.toBoolean(exprParam)).toBe(true);
expect(ParameterValueResolver.isExpression(exprParam)).toBe(true);
});
it('should handle canvas rendering scenario (text.split prevention)', () => {
// This is the actual bug we're fixing - canvas tries to call .split() on a parameter
const exprParam = createExpressionParameter('Variables.text', 'Hello\nWorld', 1);
// Before fix: this would return the object, causing text.split() to crash
// After fix: this returns a string that can be safely split
const text = ParameterValueResolver.toString(exprParam);
expect(typeof text).toBe('string');
expect(() => text.split('\n')).not.toThrow();
expect(text.split('\n')).toEqual(['Hello', 'World']);
});
it('should handle property panel display scenario', () => {
// Property panel needs to show fallback value while user edits expression
const exprParam = createExpressionParameter('2 + 2', '4', 1);
const displayValue = ParameterValueResolver.resolve(exprParam, ValueContext.Display);
expect(displayValue).toBe('4');
});
it('should handle serialization scenario', () => {
// When saving, we need the full object preserved
const exprParam = createExpressionParameter('Variables.x', 'default', 1);
const serialized = ParameterValueResolver.resolve(exprParam, ValueContext.Serialization);
expect(serialized).toBe(exprParam);
expect((serialized as ExpressionParameter).expression).toBe('Variables.x');
});
});
});

View File

@@ -1 +1,2 @@
export * from './ParameterValueResolver.test';
export * from './verify-json.spec';