// Unit-handling tests for the measurement data.measurement command. // Verifies that analog and digital modes accept the same payload shapes // (bare scalar | rich object | per-channel map) and that supplied units // are converted into the channel's configured (dropdown) unit. // // Run with: node --test test/basic/commands-units.basic.test.js 'use strict'; const test = require('node:test'); const assert = require('node:assert/strict'); const { createRegistry } = require('generalFunctions'); const commands = require('../../src/commands'); // --- helpers --------------------------------------------------------------- function makeLogger() { const calls = { warn: [], error: [], info: [], debug: [] }; return { calls, warn: (m) => calls.warn.push(String(m)), error: (m) => calls.error.push(String(m)), info: (m) => calls.info.push(String(m)), debug: (m) => calls.debug.push(String(m)), }; } // Analog source mock: exposes analogChannel.unit so the handler can resolve // the channel's configured (dropdown) unit. inputValueSets captures the // value that was eventually written, after any unit conversion. function makeAnalogSource({ unit = 'mbar' } = {}) { const inputValueSets = []; let _v = 0; return { source: { mode: 'analog', logger: makeLogger(), analogChannel: { unit }, get inputValue() { return _v; }, set inputValue(v) { _v = v; inputValueSets.push(v); }, }, inputValueSets, }; } // Digital source mock: exposes channels.get(key).unit per channel so each // digital entry can be converted independently. handleDigitalPayloadCalls // captures the *flat* {key: convertedNumber} the handler ultimately passes. function makeDigitalSource(channelUnits) { const handleDigitalPayloadCalls = []; const channels = new Map(Object.entries(channelUnits).map(([k, u]) => [k, { unit: u }])); return { source: { mode: 'digital', logger: makeLogger(), channels, handleDigitalPayload: (p) => { handleDigitalPayloadCalls.push(p); return { ok: true }; }, }, handleDigitalPayloadCalls, }; } function makeCtx({ logger = makeLogger() } = {}) { return { logger, RED: { nodes: { getNode: () => undefined } }, node: {}, send: () => {} }; } function makeRegistry(logger) { return createRegistry(commands, { logger }); } // --- analog ---------------------------------------------------------------- test('analog: bare number uses channel default unit (no conversion)', async () => { const { source, inputValueSets } = makeAnalogSource({ unit: 'mbar' }); const reg = makeRegistry(makeLogger()); await reg.dispatch({ topic: 'data.measurement', payload: 1234 }, source, makeCtx()); assert.deepEqual(inputValueSets, [1234]); }); test('analog: { value, unit } same as channel passes through unchanged', async () => { const { source, inputValueSets } = makeAnalogSource({ unit: 'mbar' }); const reg = makeRegistry(makeLogger()); await reg.dispatch( { topic: 'data.measurement', payload: { value: 500, unit: 'mbar' } }, source, makeCtx(), ); assert.deepEqual(inputValueSets, [500]); }); test('analog: { value, unit } different but compatible unit is converted', async () => { const { source, inputValueSets } = makeAnalogSource({ unit: 'mbar' }); const reg = makeRegistry(makeLogger()); // 1 bar = 1000 mbar. await reg.dispatch( { topic: 'data.measurement', payload: { value: 1, unit: 'bar' } }, source, makeCtx(), ); assert.equal(inputValueSets.length, 1); assert.ok(Math.abs(inputValueSets[0] - 1000) < 1e-6, `expected 1 bar → 1000 mbar, got ${inputValueSets[0]}`); }); test('analog: msg.unit fallback works for bare-number payloads', async () => { const { source, inputValueSets } = makeAnalogSource({ unit: 'mbar' }); const reg = makeRegistry(makeLogger()); await reg.dispatch( { topic: 'data.measurement', payload: 1, unit: 'bar' }, source, makeCtx(), ); assert.equal(inputValueSets.length, 1); assert.ok(Math.abs(inputValueSets[0] - 1000) < 1e-6, `expected 1 bar → 1000 mbar via msg.unit, got ${inputValueSets[0]}`); }); test('analog: unit-measure mismatch warns and falls back to raw value', async () => { const { source, inputValueSets } = makeAnalogSource({ unit: 'mbar' }); const ctxLogger = makeLogger(); const reg = makeRegistry(ctxLogger); await reg.dispatch( { topic: 'data.measurement', payload: { value: 42, unit: 'kg' } }, source, makeCtx({ logger: ctxLogger }), ); assert.deepEqual(inputValueSets, [42]); assert.ok( ctxLogger.calls.warn.some((m) => m.includes("'kg'") && m.includes("'mbar'")), `expected mismatch warning, got: ${JSON.stringify(ctxLogger.calls.warn)}`, ); }); test('analog: unknown unit warns and falls back to raw value', async () => { const { source, inputValueSets } = makeAnalogSource({ unit: 'mbar' }); const ctxLogger = makeLogger(); const reg = makeRegistry(ctxLogger); await reg.dispatch( { topic: 'data.measurement', payload: { value: 5, unit: 'gribbles' } }, source, makeCtx({ logger: ctxLogger }), ); assert.deepEqual(inputValueSets, [5]); assert.ok( ctxLogger.calls.warn.some((m) => m.includes("'gribbles'")), `expected unknown-unit warning, got: ${JSON.stringify(ctxLogger.calls.warn)}`, ); }); test('analog: numeric string with msg.unit is converted', async () => { const { source, inputValueSets } = makeAnalogSource({ unit: 'mbar' }); const reg = makeRegistry(makeLogger()); await reg.dispatch( { topic: 'data.measurement', payload: '2', unit: 'bar' }, source, makeCtx(), ); assert.equal(inputValueSets.length, 1); assert.ok(Math.abs(inputValueSets[0] - 2000) < 1e-6, `expected '2' bar → 2000 mbar, got ${inputValueSets[0]}`); }); // --- digital --------------------------------------------------------------- test('digital: per-channel { value, unit } converts each independently', async () => { const { source, handleDigitalPayloadCalls } = makeDigitalSource({ pIn: 'mbar', pOut: 'Pa', }); const reg = makeRegistry(makeLogger()); await reg.dispatch( { topic: 'data.measurement', payload: { pIn: { value: 1, unit: 'bar' }, // → 1000 mbar pOut: { value: 1.5, unit: 'bar' }, // → 150000 Pa }, }, source, makeCtx(), ); assert.equal(handleDigitalPayloadCalls.length, 1); const flat = handleDigitalPayloadCalls[0]; assert.ok(Math.abs(flat.pIn - 1000) < 1e-6, `pIn expected 1000, got ${flat.pIn}`); assert.ok(Math.abs(flat.pOut - 150000) < 1e-3, `pOut expected 150000, got ${flat.pOut}`); }); test('digital: bare-number entries use the channel default unit', async () => { const { source, handleDigitalPayloadCalls } = makeDigitalSource({ a: 'mbar', b: 'mbar', }); const reg = makeRegistry(makeLogger()); await reg.dispatch( { topic: 'data.measurement', payload: { a: 500, b: 750 } }, source, makeCtx(), ); assert.deepEqual(handleDigitalPayloadCalls[0], { a: 500, b: 750 }); }); test('digital: mixed rich + bare entries are converted per-channel', async () => { const { source, handleDigitalPayloadCalls } = makeDigitalSource({ a: 'mbar', b: 'mbar', }); const reg = makeRegistry(makeLogger()); await reg.dispatch( { topic: 'data.measurement', payload: { a: { value: 1, unit: 'bar' }, // converted → 1000 b: 750, // passthrough }, }, source, makeCtx(), ); const flat = handleDigitalPayloadCalls[0]; assert.ok(Math.abs(flat.a - 1000) < 1e-6, `a expected 1000, got ${flat.a}`); assert.equal(flat.b, 750); }); test('digital: msg.unit applies to bare entries when no per-channel unit is given', async () => { const { source, handleDigitalPayloadCalls } = makeDigitalSource({ a: 'mbar', b: 'mbar', }); const reg = makeRegistry(makeLogger()); await reg.dispatch( { topic: 'data.measurement', payload: { a: 1, b: 2 }, unit: 'bar' }, source, makeCtx(), ); const flat = handleDigitalPayloadCalls[0]; assert.ok(Math.abs(flat.a - 1000) < 1e-6, `a expected 1000, got ${flat.a}`); assert.ok(Math.abs(flat.b - 2000) < 1e-6, `b expected 2000, got ${flat.b}`); }); test('digital: unit-measure mismatch on one channel warns + falls back without affecting others', async () => { const { source, handleDigitalPayloadCalls } = makeDigitalSource({ pressure: 'mbar', flow: 'm3/h', }); const ctxLogger = makeLogger(); const reg = makeRegistry(ctxLogger); await reg.dispatch( { topic: 'data.measurement', payload: { pressure: { value: 1, unit: 'bar' }, // converted → 1000 flow: { value: 100, unit: 'kg' }, // mismatch → raw 100, warn }, }, source, makeCtx({ logger: ctxLogger }), ); const flat = handleDigitalPayloadCalls[0]; assert.ok(Math.abs(flat.pressure - 1000) < 1e-6, `pressure expected 1000, got ${flat.pressure}`); assert.equal(flat.flow, 100); assert.ok( ctxLogger.calls.warn.some((m) => m.includes("data.measurement[flow]") && m.includes("'kg'")), `expected per-channel mismatch warning, got: ${JSON.stringify(ctxLogger.calls.warn)}`, ); }); // --- backwards-compat ----------------------------------------------------- test('analog: { value } without unit uses channel default (rich-payload form)', async () => { const { source, inputValueSets } = makeAnalogSource({ unit: 'mbar' }); const reg = makeRegistry(makeLogger()); await reg.dispatch( { topic: 'data.measurement', payload: { value: 42 } }, source, makeCtx(), ); assert.deepEqual(inputValueSets, [42]); }); test('analog: object payload that is *not* rich still triggers switch-mode warn', async () => { const { source, inputValueSets } = makeAnalogSource({ unit: 'mbar' }); const ctxLogger = makeLogger(); const reg = makeRegistry(ctxLogger); await reg.dispatch( { topic: 'data.measurement', payload: { tempA: 21.5, tempB: 19.8 } }, source, makeCtx({ logger: ctxLogger }), ); assert.equal(inputValueSets.length, 0); assert.ok( ctxLogger.calls.warn.some((m) => m.includes('analog mode') && m.includes('digital')), `expected switch-to-digital warn, got: ${JSON.stringify(ctxLogger.calls.warn)}`, ); });