const test = require('node:test'); const assert = require('node:assert/strict'); const Measurement = require('../../src/specificClass'); /** * Integration tests for digital mode. * * Digital mode accepts an object payload where each key maps to its own * independently-configured Channel (scaling / smoothing / outlier / unit / * position). A single inbound message can therefore emit N measurements * into the MeasurementContainer in one go — the MQTT / JSON IoT pattern * the analog-centric node previously did not support. */ function makeDigitalConfig(channels, overrides = {}) { return { general: { id: 'm-dig-1', name: 'weather-station', unit: 'unitless', logging: { enabled: false, logLevel: 'error' } }, asset: { type: 'pressure', unit: 'mbar', category: 'sensor', supplier: 'vendor', model: 'BME280' }, scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: 0, absMax: 1, offset: 0 }, smoothing: { smoothWindow: 5, smoothMethod: 'none' }, simulation: { enabled: false }, functionality: { positionVsParent: 'atEquipment', distance: null }, mode: { current: 'digital' }, channels, ...overrides, }; } test('analog-mode default: no channels built, handleDigitalPayload is a no-op', () => { // Factory without mode config — defaults must stay analog. const m = new Measurement({ general: { id: 'a', name: 'a', unit: 'bar', logging: { enabled: false, logLevel: 'error' } }, asset: { type: 'pressure', unit: 'bar', category: 'sensor', supplier: 'v', model: 'M' }, scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: 0, absMax: 1, offset: 0 }, smoothing: { smoothWindow: 5, smoothMethod: 'none' }, simulation: { enabled: false }, functionality: { positionVsParent: 'atEquipment' }, }); assert.equal(m.mode, 'analog'); assert.equal(m.channels.size, 0); // In analog mode, handleDigitalPayload must refuse and not mutate state. const res = m.handleDigitalPayload({ temperature: 21 }); assert.deepEqual(res, {}); }); test('digital mode builds one Channel per config.channels entry', () => { const m = new Measurement(makeDigitalConfig([ { key: 'temperature', type: 'temperature', position: 'atEquipment', unit: 'C', scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: -50, absMax: 150, offset: 0 }, smoothing: { smoothWindow: 3, smoothMethod: 'mean' } }, { key: 'humidity', type: 'humidity', position: 'atEquipment', unit: '%', scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: 0, absMax: 100, offset: 0 }, smoothing: { smoothWindow: 3, smoothMethod: 'mean' } }, { key: 'pressure', type: 'pressure', position: 'atEquipment', unit: 'mbar', scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: 800, absMax: 1200, offset: 0 }, smoothing: { smoothWindow: 3, smoothMethod: 'mean' } }, ])); assert.equal(m.mode, 'digital'); assert.equal(m.channels.size, 3); assert.ok(m.channels.has('temperature')); assert.ok(m.channels.has('humidity')); assert.ok(m.channels.has('pressure')); }); test('digital payload routes each key to its own channel', () => { const m = new Measurement(makeDigitalConfig([ { key: 'temperature', type: 'temperature', position: 'atEquipment', unit: 'C', scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: -50, absMax: 150, offset: 0 }, smoothing: { smoothWindow: 1, smoothMethod: 'none' } }, { key: 'humidity', type: 'humidity', position: 'atEquipment', unit: '%', scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: 0, absMax: 100, offset: 0 }, smoothing: { smoothWindow: 1, smoothMethod: 'none' } }, ])); m.handleDigitalPayload({ temperature: 21.5, humidity: 65 }); const tempOut = m.channels.get('temperature').outputAbs; const humidOut = m.channels.get('humidity').outputAbs; assert.equal(tempOut, 21.5); assert.equal(humidOut, 65); }); test('digital payload emits on the MeasurementContainer per channel', async () => { const m = new Measurement(makeDigitalConfig([ { key: 't', type: 'temperature', position: 'atEquipment', unit: 'C', scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: -50, absMax: 150, offset: 0 }, smoothing: { smoothWindow: 1, smoothMethod: 'none' } }, { key: 'h', type: 'humidity', position: 'atEquipment', unit: '%', scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: 0, absMax: 100, offset: 0 }, smoothing: { smoothWindow: 1, smoothMethod: 'none' } }, ])); const events = []; m.measurements.emitter.on('temperature.measured.atequipment', (e) => events.push({ on: 't', value: e.value })); m.measurements.emitter.on('humidity.measured.atequipment', (e) => events.push({ on: 'h', value: e.value })); m.handleDigitalPayload({ t: 22, h: 50 }); await new Promise((r) => setImmediate(r)); assert.equal(events.filter((e) => e.on === 't').length, 1); assert.equal(events.filter((e) => e.on === 'h').length, 1); assert.equal(events.find((e) => e.on === 't').value, 22); assert.equal(events.find((e) => e.on === 'h').value, 50); }); test('digital payload with unmapped keys silently ignores them', () => { const m = new Measurement(makeDigitalConfig([ { key: 't', type: 'temperature', position: 'atEquipment', unit: 'C', scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: -50, absMax: 150, offset: 0 }, smoothing: { smoothWindow: 1, smoothMethod: 'none' } }, ])); const res = m.handleDigitalPayload({ t: 20, unknown: 999, extra: 'x' }); assert.equal(m.channels.get('t').outputAbs, 20); assert.equal(res.t.ok, true); assert.equal(res.unknown, undefined); assert.equal(res.extra, undefined); }); test('digital channel with scaling enabled maps input to abs range', () => { const m = new Measurement(makeDigitalConfig([ { key: 'pt', type: 'pressure', position: 'atEquipment', unit: 'mbar', scaling: { enabled: true, inputMin: 0, inputMax: 100, absMin: 0, absMax: 1000, offset: 0 }, smoothing: { smoothWindow: 1, smoothMethod: 'none' } }, ])); m.handleDigitalPayload({ pt: 50 }); // 50% of [0..100] -> 50% of [0..1000] = 500 assert.equal(m.channels.get('pt').outputAbs, 500); }); test('digital channel smoothing accumulates per-channel, independent of siblings', () => { const m = new Measurement(makeDigitalConfig([ { key: 't', type: 'temperature', position: 'atEquipment', unit: 'C', scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: -50, absMax: 150, offset: 0 }, smoothing: { smoothWindow: 3, smoothMethod: 'mean' } }, { key: 'h', type: 'humidity', position: 'atEquipment', unit: '%', scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: 0, absMax: 100, offset: 0 }, smoothing: { smoothWindow: 3, smoothMethod: 'mean' } }, ])); // Feed only temperature across 3 pushes; humidity never receives a value. m.handleDigitalPayload({ t: 10 }); m.handleDigitalPayload({ t: 20 }); m.handleDigitalPayload({ t: 30 }); assert.equal(m.channels.get('t').outputAbs, 20); // mean(10,20,30)=20 assert.equal(m.channels.get('t').storedValues.length, 3); // Humidity channel must be untouched. assert.equal(m.channels.get('h').storedValues.length, 0); assert.equal(m.channels.get('h').outputAbs, 0); }); test('digital channel rejects non-numeric values in summary', () => { const m = new Measurement(makeDigitalConfig([ { key: 't', type: 'temperature', position: 'atEquipment', unit: 'C', scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: -50, absMax: 150, offset: 0 }, smoothing: { smoothWindow: 1, smoothMethod: 'none' } }, ])); const res = m.handleDigitalPayload({ t: 'banana' }); assert.equal(res.t.ok, false); assert.equal(res.t.reason, 'non-numeric'); assert.equal(m.channels.get('t').outputAbs, 0); }); test('digital channel supports per-channel outlier detection', () => { const m = new Measurement(makeDigitalConfig([ { key: 't', type: 'temperature', position: 'atEquipment', unit: 'C', scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: -50, absMax: 150, offset: 0 }, smoothing: { smoothWindow: 10, smoothMethod: 'none' }, outlierDetection: { enabled: true, method: 'zscore', threshold: 3 } }, ])); // Seed a tight baseline then lob an obvious spike. for (const v of [20, 20, 20, 20, 20, 20]) m.handleDigitalPayload({ t: v }); const baselineOut = m.channels.get('t').outputAbs; m.handleDigitalPayload({ t: 1e6 }); assert.equal(m.channels.get('t').outputAbs, baselineOut, 'spike must be rejected as outlier'); }); test('getDigitalOutput produces one entry per channel', () => { const m = new Measurement(makeDigitalConfig([ { key: 't', type: 'temperature', position: 'atEquipment', unit: 'C', scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: -50, absMax: 150, offset: 0 }, smoothing: { smoothWindow: 1, smoothMethod: 'none' } }, { key: 'h', type: 'humidity', position: 'atEquipment', unit: '%', scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: 0, absMax: 100, offset: 0 }, smoothing: { smoothWindow: 1, smoothMethod: 'none' } }, ])); m.handleDigitalPayload({ t: 25, h: 40 }); const out = m.getDigitalOutput(); assert.ok(out.channels.t); assert.ok(out.channels.h); assert.equal(out.channels.t.mAbs, 25); assert.equal(out.channels.h.mAbs, 40); assert.equal(out.channels.t.type, 'temperature'); assert.equal(out.channels.h.unit, '%'); }); test('digital mode with empty channels array still constructs cleanly', () => { const m = new Measurement(makeDigitalConfig([])); assert.equal(m.mode, 'digital'); assert.equal(m.channels.size, 0); // No throw on empty payload. assert.deepEqual(m.handleDigitalPayload({ anything: 1 }), {}); }); test('digital mode ignores malformed channel entries in config', () => { const m = new Measurement(makeDigitalConfig([ { key: 'valid', type: 'temperature', position: 'atEquipment', unit: 'C', scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: 0, absMax: 100, offset: 0 }, smoothing: { smoothWindow: 1, smoothMethod: 'none' } }, null, // malformed { key: 'no_type' }, // missing type { type: 'pressure' }, // missing key ])); assert.equal(m.channels.size, 1); assert.ok(m.channels.has('valid')); });