Files
measurement/test/basic/commands-units.basic.test.js
znetsixe 5d79314229 feat(units) + style: command unit-handling, frost dbase option, palette #D4A02E
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>
2026-05-21 15:06:37 +02:00

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)}`,
);
});