agent updates

This commit is contained in:
znetsixe
2026-02-12 10:14:56 +01:00
parent 105a3082ab
commit 1cfb36f604
22 changed files with 649 additions and 23 deletions

View File

@@ -16,7 +16,7 @@ class Assertions {
assertNoNaN(arr, label = "array") {
if (Array.isArray(arr)) {
for (const el of arr) {
assertNoNaN(el, label);
this.assertNoNaN(el, label);
}
} else {
if (Number.isNaN(arr)) {
@@ -26,4 +26,4 @@ class Assertions {
}
}
module.exports = Assertions;
module.exports = Assertions;

View File

@@ -39,8 +39,8 @@ const Logger = require("./logger");
class ConfigUtils {
constructor(defaultConfig, IloggerEnabled , IloggerLevel) {
const loggerEnabled = IloggerEnabled || true;
const loggerLevel = IloggerLevel || "warn";
const loggerEnabled = IloggerEnabled ?? true;
const loggerLevel = IloggerLevel ?? "warn";
this.logger = new Logger(loggerEnabled, loggerLevel, 'ConfigUtils');
this.defaultConfig = defaultConfig;
this.validationUtils = new ValidationUtils(loggerEnabled, loggerLevel);

View File

@@ -44,7 +44,7 @@ class Logger {
if (this.levels.includes(level)) {
this.logLevel = level;
} else {
console.error(`[ERROR ${nameModule}]: Invalid log level: ${level}`);
console.error(`[ERROR] -> ${this.nameModule}: Invalid log level: ${level}`);
}
}
@@ -54,4 +54,4 @@ class Logger {
}
}
module.exports = Logger;
module.exports = Logger;

View File

@@ -36,8 +36,8 @@ const Logger = require("./logger");
class ValidationUtils {
constructor(IloggerEnabled, IloggerLevel) {
const loggerEnabled = IloggerEnabled || true;
const loggerLevel = IloggerLevel || "warn";
const loggerEnabled = IloggerEnabled ?? true;
const loggerLevel = IloggerLevel ?? "warn";
this.logger = new Logger(loggerEnabled, loggerLevel, 'ValidationUtils');
}
@@ -191,7 +191,7 @@ class ValidationUtils {
continue;
}
if("default" in v){
if(v && typeof v === "object" && "default" in v){
//put the default value in the object
newObj[k] = v.default;
continue;
@@ -496,6 +496,11 @@ class ValidationUtils {
return fieldSchema.default;
}
if (typeof configValue !== "string") {
this.logger.warn(`${name}.${key} is not a valid enum string. Using default value.`);
return fieldSchema.default;
}
const validValues = rules.values.map(e => e.value.toLowerCase());
//remove caps

View File

@@ -69,8 +69,10 @@ class Measurement {
}
getLaggedValue(lag){
if(this.values.length <= lag) return null;
return this.values[this.values.length - lag];
if (lag < 0) throw new Error('lag must be >= 0');
const index = this.values.length - 1 - lag;
if (index < 0) return null;
return this.values[index];
}
getLaggedSample(lag){
@@ -178,7 +180,7 @@ class Measurement {
try {
const convertedValues = this.values.map(value =>
convertModule.convert(value).from(this.unit).to(targetUnit)
convertModule(value).from(this.unit).to(targetUnit)
);
const newMeasurement = new Measurement(

View File

@@ -332,7 +332,7 @@ class MeasurementContainer {
// Convert if needed
if (measurement.unit && requestedUnit !== measurement.unit) {
try {
const convertedValue = convertModule(value).from(measurement.unit).to(requestedUnit);
const convertedValue = convertModule(sample.value).from(measurement.unit).to(requestedUnit);
//replace old value in sample and return obj
sample.value = convertedValue ;
sample.unit = requestedUnit;
@@ -364,7 +364,7 @@ class MeasurementContainer {
// Convert if needed
if (measurement.unit && requestedUnit !== measurement.unit) {
try {
const convertedValue = convertModule(value).from(measurement.unit).to(requestedUnit);
const convertedValue = convertModule(sample.value).from(measurement.unit).to(requestedUnit);
//replace old value in sample and return obj
sample.value = convertedValue ;
sample.unit = requestedUnit;
@@ -619,16 +619,16 @@ class MeasurementContainer {
}
_convertPositionNum2Str(positionValue) {
switch (positionValue) {
case 0:
if (positionValue === 0) {
return "atEquipment";
case (positionValue < 0):
return "upstream";
case (positionValue > 0):
return "downstream";
default:
console.log(`Invalid position provided: ${positionValue}`);
}
if (positionValue < 0) {
return "upstream";
}
if (positionValue > 0) {
return "downstream";
}
console.log(`Invalid position provided: ${positionValue}`);
}
}

View File

@@ -36,7 +36,21 @@ class MenuManager {
try {
const config = this.configManager.getConfig(nodeName);
return config?.functionality?.softwareType || nodeName;
const softwareType = config?.functionality?.softwareType;
if (typeof softwareType === 'string' && softwareType.trim()) {
return softwareType;
}
if (
softwareType &&
typeof softwareType === 'object' &&
typeof softwareType.default === 'string' &&
softwareType.default.trim()
) {
return softwareType.default;
}
return nodeName;
} catch (error) {
console.warn(`Unable to determine softwareType for ${nodeName}: ${error.message}`);
return nodeName;

View File

@@ -0,0 +1,42 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const barrel = require('../index.js');
test('barrel exports expected public members', () => {
const expected = [
'predict',
'interpolation',
'configManager',
'assetApiConfig',
'outputUtils',
'configUtils',
'logger',
'validation',
'assertions',
'MeasurementContainer',
'nrmse',
'state',
'coolprop',
'convert',
'MenuManager',
'childRegistrationUtils',
'loadCurve',
'loadModel',
'gravity',
];
for (const key of expected) {
assert.ok(key in barrel, `missing export: ${key}`);
}
});
test('barrel types are callable where expected', () => {
assert.equal(typeof barrel.logger, 'function');
assert.equal(typeof barrel.validation, 'function');
assert.equal(typeof barrel.configUtils, 'function');
assert.equal(typeof barrel.outputUtils, 'function');
assert.equal(typeof barrel.MeasurementContainer, 'function');
assert.equal(typeof barrel.convert, 'function');
assert.equal(typeof barrel.gravity.getStandardGravity, 'function');
});

14
test/assertions.test.js Normal file
View File

@@ -0,0 +1,14 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const Assertions = require('../src/helper/assertionUtils.js');
test('assertNoNaN does not throw for valid nested arrays', () => {
const assertions = new Assertions();
assert.doesNotThrow(() => assertions.assertNoNaN([1, [2, 3, [4]]]));
});
test('assertNoNaN throws when NaN exists in nested arrays', () => {
const assertions = new Assertions();
assert.throws(() => assertions.assertNoNaN([1, [2, NaN]]), /NaN detected/);
});

View File

@@ -0,0 +1,55 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const ChildRegistrationUtils = require('../src/helper/childRegistrationUtils.js');
function makeMainClass() {
return {
logger: {
debug() {},
info() {},
warn() {},
error() {},
},
child: {},
registerChildCalls: [],
registerChild(child, softwareType) {
this.registerChildCalls.push({ child, softwareType });
},
};
}
test('registerChild wires parent, measurement context, and storage', async () => {
const mainClass = makeMainClass();
const utils = new ChildRegistrationUtils(mainClass);
const measurementContext = {
childId: null,
childName: null,
parentRef: null,
setChildId(v) { this.childId = v; },
setChildName(v) { this.childName = v; },
setParentRef(v) { this.parentRef = v; },
};
const child = {
config: {
functionality: { softwareType: 'measurement' },
general: { name: 'PT1', id: 'child-1' },
asset: { category: 'sensor' },
},
measurements: measurementContext,
};
await utils.registerChild(child, 'upstream');
assert.deepEqual(child.parent, [mainClass]);
assert.equal(child.positionVsParent, 'upstream');
assert.equal(measurementContext.childId, 'child-1');
assert.equal(measurementContext.childName, 'PT1');
assert.equal(measurementContext.parentRef, mainClass);
assert.equal(mainClass.child.measurement.sensor.length, 1);
assert.equal(utils.getChildById('child-1'), child);
assert.equal(mainClass.registerChildCalls.length, 1);
});

View File

@@ -0,0 +1,33 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const ConfigManager = require('../src/configs/index.js');
test('can read known config and report existence', () => {
const manager = new ConfigManager('.');
assert.equal(manager.hasConfig('measurement'), true);
const config = manager.getConfig('measurement');
assert.ok(config.functionality);
assert.ok(config.functionality.softwareType);
});
test('getAvailableConfigs includes known names', () => {
const manager = new ConfigManager('.');
const configs = manager.getAvailableConfigs();
assert.ok(configs.includes('measurement'));
assert.ok(configs.includes('rotatingMachine'));
});
test('createEndpoint creates executable JS payload shell', () => {
const manager = new ConfigManager('.');
const script = manager.createEndpoint('measurement');
assert.match(script, /window\.EVOLV\.nodes\.measurement/);
assert.match(script, /config loaded and endpoint created/);
});
test('getConfig throws on missing config', () => {
const manager = new ConfigManager('.');
assert.throws(() => manager.getConfig('definitely-not-real'), /Failed to load config/);
});

51
test/config-utils.test.js Normal file
View File

@@ -0,0 +1,51 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const ConfigUtils = require('../src/helper/configUtils.js');
const defaultConfig = {
functionality: {
softwareType: {
default: 'measurement',
rules: { type: 'string' },
},
},
general: {
logging: {
enabled: { default: true, rules: { type: 'boolean' } },
logLevel: {
default: 'info',
rules: {
type: 'enum',
values: [{ value: 'debug' }, { value: 'info' }, { value: 'warn' }, { value: 'error' }],
},
},
},
name: { default: 'default-name', rules: { type: 'string' } },
},
scaling: {
absMin: { default: 0, rules: { type: 'number' } },
absMax: { default: 100, rules: { type: 'number' } },
},
};
test('initConfig applies defaults', () => {
const cfg = new ConfigUtils(defaultConfig, false, 'error');
const result = cfg.initConfig({});
assert.equal(result.general.name, 'default-name');
assert.equal(result.scaling.absMax, 100);
});
test('updateConfig merges nested overrides and revalidates', () => {
const cfg = new ConfigUtils(defaultConfig, false, 'error');
const base = cfg.initConfig({ general: { name: 'sensor-a' } });
const updated = cfg.updateConfig(base, { scaling: { absMax: 150 } });
assert.equal(updated.general.name, 'sensor-a');
assert.equal(updated.scaling.absMax, 150);
});
test('constructor respects explicit logger disabled flag', () => {
const cfg = new ConfigUtils(defaultConfig, false, 'error');
assert.equal(cfg.logger.logging, false);
});

21
test/gravity.test.js Normal file
View File

@@ -0,0 +1,21 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const gravity = require('../src/helper/gravity.js');
test('standard gravity constant is available', () => {
assert.ok(Math.abs(gravity.getStandardGravity() - 9.80665) < 1e-9);
});
test('local gravity decreases with elevation', () => {
const seaLevel = gravity.getLocalGravity(45, 0);
const high = gravity.getLocalGravity(45, 1000);
assert.ok(high < seaLevel);
});
test('pressureHead and weightForce use local gravity', () => {
const dp = gravity.pressureHead(1000, 5, 45, 0);
const force = gravity.weightForce(2, 45, 0);
assert.ok(dp > 0);
assert.ok(force > 0);
});

24
test/helpers.js Normal file
View File

@@ -0,0 +1,24 @@
const path = require('node:path');
function makeLogger() {
return {
debug() {},
info() {},
warn() {},
error() {},
};
}
function near(actual, expected, epsilon = 1e-6) {
return Math.abs(actual - expected) <= epsilon;
}
function fixturePath(...segments) {
return path.join(__dirname, ...segments);
}
module.exports = {
makeLogger,
near,
fixturePath,
};

65
test/logger.test.js Normal file
View File

@@ -0,0 +1,65 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const Logger = require('../src/helper/logger.js');
function withPatchedConsole(fn) {
const original = {
debug: console.debug,
info: console.info,
warn: console.warn,
error: console.error,
};
const calls = [];
console.debug = (...args) => calls.push(['debug', ...args]);
console.info = (...args) => calls.push(['info', ...args]);
console.warn = (...args) => calls.push(['warn', ...args]);
console.error = (...args) => calls.push(['error', ...args]);
try {
fn(calls);
} finally {
console.debug = original.debug;
console.info = original.info;
console.warn = original.warn;
console.error = original.error;
}
}
test('respects log level threshold', () => {
withPatchedConsole((calls) => {
const logger = new Logger(true, 'warn', 'T');
logger.debug('a');
logger.info('b');
logger.warn('c');
logger.error('d');
const levels = calls.map((c) => c[0]);
assert.deepEqual(levels, ['warn', 'error']);
});
});
test('toggleLogging disables output', () => {
withPatchedConsole((calls) => {
const logger = new Logger(true, 'debug', 'T');
logger.toggleLogging();
logger.debug('x');
logger.error('y');
assert.equal(calls.length, 0);
});
});
test('setLogLevel updates to valid level', () => {
const logger = new Logger(true, 'debug', 'T');
logger.setLogLevel('error');
assert.equal(logger.logLevel, 'error');
});
test('setLogLevel with invalid value should not throw', () => {
withPatchedConsole(() => {
const logger = new Logger(true, 'debug', 'T');
assert.doesNotThrow(() => logger.setLogLevel('invalid-level'));
assert.equal(logger.logLevel, 'debug');
});
});

View File

@@ -0,0 +1,29 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const MeasurementBuilder = require('../src/measurements/MeasurementBuilder.js');
test('builder requires mandatory fields', () => {
assert.throws(() => new MeasurementBuilder().build(), /Measurement type is required/);
assert.throws(() => new MeasurementBuilder().setType('flow').build(), /Measurement variant is required/);
assert.throws(
() => new MeasurementBuilder().setType('flow').setVariant('measured').build(),
/Measurement position is required/
);
});
test('builder creates measurement with provided config', () => {
const measurement = new MeasurementBuilder()
.setType('flow')
.setVariant('measured')
.setPosition('upstream')
.setWindowSize(25)
.setDistance(3.2)
.build();
assert.equal(measurement.type, 'flow');
assert.equal(measurement.variant, 'measured');
assert.equal(measurement.position, 'upstream');
assert.equal(measurement.windowSize, 25);
assert.equal(measurement.distance, 3.2);
});

View File

@@ -0,0 +1,61 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const MeasurementContainer = require('../src/measurements/MeasurementContainer.js');
function makeContainer() {
return new MeasurementContainer({
windowSize: 10,
defaultUnits: {
flow: 'm3/h',
pressure: 'mbar',
},
});
}
test('stores and retrieves measurements via chain API', () => {
const c = makeContainer();
c.type('flow').variant('measured').position('upstream').value(100, 1, 'm3/h');
assert.equal(c.type('flow').variant('measured').position('upstream').getCurrentValue(), 100);
assert.equal(c.type('flow').variant('measured').position('upstream').exists(), true);
});
test('distance(null) auto-derives from position mapping', () => {
const c = makeContainer();
c.type('pressure').variant('measured').position('upstream').distance(null).value(5, 1, 'mbar');
const m = c.type('pressure').variant('measured').position('upstream').get();
assert.equal(m.distance, Number.POSITIVE_INFINITY);
});
test('getLaggedSample with requested unit converts sample value', () => {
const c = makeContainer();
c.type('flow').variant('measured').position('upstream').value(3.6, 1, 'm3/h');
c.type('flow').variant('measured').position('upstream').value(7.2, 2, 'm3/h');
const previous = c.type('flow').variant('measured').position('upstream').getLaggedSample(1, 'm3/s');
assert.ok(previous);
assert.equal(previous.unit, 'm3/s');
assert.ok(Math.abs(previous.value - 0.001) < 1e-8);
});
test('difference computes current and average delta between positions', () => {
const c = makeContainer();
c.type('pressure').variant('measured').position('downstream').value(120, 1, 'mbar');
c.type('pressure').variant('measured').position('downstream').value(130, 2, 'mbar');
c.type('pressure').variant('measured').position('upstream').value(100, 1, 'mbar');
c.type('pressure').variant('measured').position('upstream').value(110, 2, 'mbar');
const diff = c.type('pressure').variant('measured').difference();
assert.equal(diff.value, 20);
assert.equal(diff.avgDiff, 20);
assert.equal(diff.unit, 'mbar');
});
test('_convertPositionNum2Str maps signs to labels', () => {
const c = makeContainer();
assert.equal(c._convertPositionNum2Str(0), 'atEquipment');
assert.equal(c._convertPositionNum2Str(1), 'downstream');
assert.equal(c._convertPositionNum2Str(-1), 'upstream');
});

49
test/measurement.test.js Normal file
View File

@@ -0,0 +1,49 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const Measurement = require('../src/measurements/Measurement.js');
const { near } = require('./helpers.js');
test('maintains rolling window and exposes stats', () => {
const m = new Measurement('flow', 'measured', 'upstream', 3);
m.setValue(10, 1).setValue(20, 2).setValue(30, 3).setValue(40, 4);
assert.deepEqual(m.getAllValues().values, [20, 30, 40]);
assert.deepEqual(m.getAllValues().timestamps, [2, 3, 4]);
assert.equal(m.getCurrentValue(), 40);
assert.equal(m.getAverage(), 30);
assert.equal(m.getMin(), 20);
assert.equal(m.getMax(), 40);
});
test('lag semantics: lag=1 is previous sample', () => {
const m = new Measurement('flow', 'measured', 'upstream', 5);
m.setValue(10, 100).setValue(20, 200).setValue(30, 300);
assert.equal(m.getLaggedSample(0).value, 30);
assert.equal(m.getLaggedSample(1).value, 20);
assert.equal(m.getLaggedValue(1), 20);
});
test('convertTo converts values to target unit', () => {
const m = new Measurement('flow', 'measured', 'upstream', 5);
m.setUnit('m3/h');
m.setValue(3.6, 1);
const converted = m.convertTo('m3/s');
assert.ok(near(converted.getCurrentValue(), 0.001, 1e-8));
assert.equal(converted.unit, 'm3/s');
assert.equal(converted.getLatestTimestamp(), 1);
});
test('createDifference aligns timestamps and subtracts downstream from upstream', () => {
const up = new Measurement('pressure', 'measured', 'upstream', 10).setUnit('mbar');
const down = new Measurement('pressure', 'measured', 'downstream', 10).setUnit('mbar');
up.setValue(120, 1).setValue(140, 2);
down.setValue(100, 2).setValue(95, 3);
const diff = Measurement.createDifference(up, down);
assert.deepEqual(diff.getAllValues().timestamps, [2]);
assert.deepEqual(diff.getAllValues().values, [40]);
});

20
test/menu-manager.test.js Normal file
View File

@@ -0,0 +1,20 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const MenuManager = require('../src/menu/index.js');
test('createEndpoint returns script including initEditor and menuData', () => {
const manager = new MenuManager();
const script = manager.createEndpoint('measurement', ['asset', 'logger', 'position']);
assert.match(script, /window\.EVOLV\.nodes\.measurement\.initEditor/);
assert.match(script, /window\.EVOLV\.nodes\.measurement\.menuData/);
});
test('_getSoftwareType resolves to string identifier', () => {
const manager = new MenuManager();
const softwareType = manager._getSoftwareType('measurement');
assert.equal(typeof softwareType, 'string');
assert.equal(softwareType, 'measurement');
});

37
test/nrmse.test.js Normal file
View File

@@ -0,0 +1,37 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const ErrorMetrics = require('../src/nrmse/errorMetrics.js');
const { makeLogger } = require('./helpers.js');
test('MSE and RMSE calculations are correct', () => {
const m = new ErrorMetrics({}, makeLogger());
const predicted = [1, 2, 3];
const measured = [1, 3, 5];
assert.ok(Math.abs(m.meanSquaredError(predicted, measured) - 5 / 3) < 1e-9);
assert.ok(Math.abs(m.rootMeanSquaredError(predicted, measured) - Math.sqrt(5 / 3)) < 1e-9);
});
test('normalizeUsingRealtime throws when range is zero', () => {
const m = new ErrorMetrics({}, makeLogger());
assert.throws(() => m.normalizeUsingRealtime([1, 1, 1], [1, 1, 1]), /Invalid process range/);
});
test('longTermNRMSD returns 0 before 100 samples and value after', () => {
const m = new ErrorMetrics({}, makeLogger());
for (let i = 0; i < 99; i++) {
assert.equal(m.longTermNRMSD(0.1), 0);
}
assert.notEqual(m.longTermNRMSD(0.2), 0);
});
test('assessDrift returns expected result envelope', () => {
const m = new ErrorMetrics({}, makeLogger());
const out = m.assessDrift([100, 101, 102], [99, 100, 103], 90, 110);
assert.equal(typeof out.nrmse, 'number');
assert.equal(typeof out.longTermNRMSD, 'number');
assert.ok('immediateLevel' in out);
assert.ok('longTermLevel' in out);
});

42
test/output-utils.test.js Normal file
View File

@@ -0,0 +1,42 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const OutputUtils = require('../src/helper/outputUtils.js');
const config = {
functionality: { softwareType: 'measurement', role: 'sensor' },
general: { id: 'abc', unit: 'mbar' },
asset: {
uuid: 'u1',
tagcode: 't1',
geoLocation: { lat: 51.6, lon: 4.7 },
category: 'measurement',
type: 'pressure',
model: 'M1',
},
};
test('process format emits message with changed fields only', () => {
const out = new OutputUtils();
const first = out.formatMsg({ a: 1, b: 2 }, config, 'process');
assert.equal(first.topic, 'measurement_abc');
assert.deepEqual(first.payload, { a: 1, b: 2 });
const second = out.formatMsg({ a: 1, b: 2 }, config, 'process');
assert.equal(second, undefined);
const third = out.formatMsg({ a: 1, b: 3, c: { x: 1 } }, config, 'process');
assert.deepEqual(third.payload, { b: 3, c: JSON.stringify({ x: 1 }) });
});
test('influx format flattens tags and stringifies tag values', () => {
const out = new OutputUtils();
const msg = out.formatMsg({ value: 10 }, config, 'influxdb');
assert.equal(msg.topic, 'measurement_abc');
assert.equal(msg.payload.measurement, 'measurement_abc');
assert.equal(msg.payload.tags.geoLocation_lat, '51.6');
assert.equal(msg.payload.tags.geoLocation_lon, '4.7');
assert.ok(msg.payload.timestamp instanceof Date);
});

View File

@@ -0,0 +1,62 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const ValidationUtils = require('../src/helper/validationUtils.js');
const schema = {
functionality: {
softwareType: {
default: 'measurement',
rules: { type: 'string' },
},
},
enabled: {
default: true,
rules: { type: 'boolean' },
},
mode: {
default: 'auto',
rules: {
type: 'enum',
values: [{ value: 'auto' }, { value: 'manual' }],
},
},
name: {
default: 'sensor',
rules: { type: 'string' },
},
};
test('validateSchema applies defaults and type coercion where supported', () => {
const validation = new ValidationUtils(false, 'error');
const result = validation.validateSchema({ enabled: 'true', name: 'SENSOR' }, schema, 'test');
assert.equal(result.enabled, true);
assert.equal(result.name, 'sensor');
assert.equal(result.mode, 'auto');
assert.equal(result.functionality.softwareType, 'measurement');
});
test('enum with non-string value falls back to default', () => {
const validation = new ValidationUtils(false, 'error');
const result = validation.validateSchema({ mode: 123 }, schema, 'test');
assert.equal(result.mode, 'auto');
});
test('curve validation falls back to default for invalid dimension structure', () => {
const validation = new ValidationUtils(false, 'error');
const defaultCurve = { 1: { x: [1, 2], y: [10, 20] } };
const invalid = { 1: { x: [2, 1], y: [20, 10] } };
const curve = validation.validateCurve(invalid, defaultCurve);
assert.deepEqual(curve, defaultCurve);
});
test('removeUnwantedKeys handles primitive values without throwing', () => {
const validation = new ValidationUtils(false, 'error');
const input = {
a: { default: 1, rules: { type: 'number' } },
b: 2,
c: 'x',
};
assert.doesNotThrow(() => validation.removeUnwantedKeys(input));
});