measurement.html:
• sidebar swatch → #D4A02E (amber, sensor family) — EVOLV palette redesign
2026-05-21 (see superproject .claude/rules/node-red-flow-layout.md §10.0).
• Add "frost" option to dbaseOutputFormat dropdown (CoreSync FROST handoff).
src/commands/handlers.js + test/basic/commands-units.basic.test.js:
• Unit handling for data.measurement command. Analog + digital modes both
accept scalar / object / per-channel-map payloads; supplied units are
converted into the channel's configured (dropdown) unit.
CONTRACT.md: document the unit semantics.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
324 lines
10 KiB
JavaScript
324 lines
10 KiB
JavaScript
// 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)}`,
|
|
);
|
|
});
|