before functional changes by codex

This commit is contained in:
znetsixe
2026-02-19 17:37:21 +01:00
parent f979b1ae2b
commit 9e0e3e3859
18 changed files with 747 additions and 1 deletions

21
examples/README.md Normal file
View File

@@ -0,0 +1,21 @@
# Measurement Example Flows
These flows are import-ready Node-RED examples for the `measurement` node.
## Files
- `basic.flow.json`
Purpose: basic measurement injection and output inspection.
- `integration.flow.json`
Purpose: parent/child registration and periodic measurement updates.
- `edge.flow.json`
Purpose: invalid/edge payload driving for robustness checks.
## Requirements
- EVOLV `measurement` node available in Node-RED.
## Import
1. Open Node-RED import.
2. Import one `*.flow.json` file.
3. Deploy and inspect debug output.

111
examples/basic.flow.json Normal file
View File

@@ -0,0 +1,111 @@
[
{
"id": "m_tab_basic_1",
"type": "tab",
"label": "Measurement Basic",
"disabled": false,
"info": "Basic measurement flow"
},
{
"id": "m_basic_node",
"type": "measurement",
"z": "m_tab_basic_1",
"name": "M Basic",
"scaling": true,
"i_min": 0,
"i_max": 100,
"i_offset": 0,
"o_min": 0,
"o_max": 10,
"simulator": false,
"smooth_method": "mean",
"count": 5,
"uuid": "",
"supplier": "vendor",
"category": "sensor",
"assetType": "pressure",
"model": "PT-1",
"unit": "bar",
"assetTagNumber": "PT-001",
"enableLog": false,
"logLevel": "error",
"positionVsParent": "atEquipment",
"positionIcon": "",
"hasDistance": false,
"distance": 0,
"distanceUnit": "m",
"distanceDescription": "",
"x": 510,
"y": 220,
"wires": [["m_basic_dbg_process"],["m_basic_dbg_influx"],["m_basic_dbg_parent"]]
},
{
"id": "m_basic_inject_measurement",
"type": "inject",
"z": "m_tab_basic_1",
"name": "measurement 42",
"props": [{"p": "topic", "vt": "str"},{"p": "payload", "vt": "num"}],
"topic": "measurement",
"payload": "42",
"payloadType": "num",
"x": 170,
"y": 220,
"wires": [["m_basic_node"]]
},
{
"id": "m_basic_inject_calibrate",
"type": "inject",
"z": "m_tab_basic_1",
"name": "calibrate",
"props": [{"p": "topic", "vt": "str"}],
"topic": "calibrate",
"x": 140,
"y": 170,
"wires": [["m_basic_node"]]
},
{
"id": "m_basic_dbg_process",
"type": "debug",
"z": "m_tab_basic_1",
"name": "M process",
"active": true,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "true",
"targetType": "full",
"x": 750,
"y": 180,
"wires": []
},
{
"id": "m_basic_dbg_influx",
"type": "debug",
"z": "m_tab_basic_1",
"name": "M influx",
"active": true,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "true",
"targetType": "full",
"x": 740,
"y": 220,
"wires": []
},
{
"id": "m_basic_dbg_parent",
"type": "debug",
"z": "m_tab_basic_1",
"name": "M parent",
"active": true,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "true",
"targetType": "full",
"x": 740,
"y": 260,
"wires": []
}
]

120
examples/edge.flow.json Normal file
View File

@@ -0,0 +1,120 @@
[
{
"id": "m_tab_edge_1",
"type": "tab",
"label": "Measurement Edge",
"disabled": false,
"info": "Edge-case measurement flow"
},
{
"id": "m_edge_node",
"type": "measurement",
"z": "m_tab_edge_1",
"name": "M Edge",
"scaling": true,
"i_min": 0,
"i_max": 100,
"i_offset": 0,
"o_min": 0,
"o_max": 10,
"simulator": false,
"smooth_method": "mean",
"count": 5,
"supplier": "vendor",
"category": "sensor",
"assetType": "pressure",
"model": "PT-E",
"unit": "bar",
"positionVsParent": "atEquipment",
"hasDistance": false,
"distance": 0,
"distanceUnit": "m",
"enableLog": false,
"logLevel": "error",
"x": 510,
"y": 220,
"wires": [["m_edge_dbg_process"],["m_edge_dbg_influx"],["m_edge_dbg_parent"]]
},
{
"id": "m_edge_bad_payload",
"type": "inject",
"z": "m_tab_edge_1",
"name": "measurement bad payload",
"props": [{"p": "topic", "vt": "str"},{"p": "payload", "vt": "str"}],
"topic": "measurement",
"payload": "not-a-number",
"payloadType": "str",
"x": 170,
"y": 170,
"wires": [["m_edge_node"]]
},
{
"id": "m_edge_toggle_outlier",
"type": "inject",
"z": "m_tab_edge_1",
"name": "toggle outlier",
"props": [{"p": "topic", "vt": "str"}],
"topic": "outlierDetection",
"x": 140,
"y": 220,
"wires": [["m_edge_node"]]
},
{
"id": "m_edge_unknown_topic",
"type": "inject",
"z": "m_tab_edge_1",
"name": "unknown topic",
"props": [{"p": "topic", "vt": "str"},{"p": "payload", "vt": "num"}],
"topic": "doesNotExist",
"payload": "1",
"payloadType": "num",
"x": 150,
"y": 270,
"wires": [["m_edge_node"]]
},
{
"id": "m_edge_dbg_process",
"type": "debug",
"z": "m_tab_edge_1",
"name": "M edge process",
"active": true,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "true",
"targetType": "full",
"x": 750,
"y": 180,
"wires": []
},
{
"id": "m_edge_dbg_influx",
"type": "debug",
"z": "m_tab_edge_1",
"name": "M edge influx",
"active": true,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "true",
"targetType": "full",
"x": 740,
"y": 220,
"wires": []
},
{
"id": "m_edge_dbg_parent",
"type": "debug",
"z": "m_tab_edge_1",
"name": "M edge parent",
"active": true,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "true",
"targetType": "full",
"x": 740,
"y": 260,
"wires": []
}
]

View File

@@ -0,0 +1,142 @@
[
{
"id": "m_tab_int_1",
"type": "tab",
"label": "Measurement Integration",
"disabled": false,
"info": "Integration-oriented measurement flow"
},
{
"id": "m_int_parent",
"type": "measurement",
"z": "m_tab_int_1",
"name": "M Parent",
"scaling": true,
"i_min": 0,
"i_max": 100,
"i_offset": 0,
"o_min": 0,
"o_max": 10,
"simulator": false,
"smooth_method": "mean",
"count": 5,
"supplier": "vendor",
"category": "sensor",
"assetType": "pressure",
"model": "PT-P",
"unit": "bar",
"positionVsParent": "atEquipment",
"hasDistance": false,
"distance": 0,
"distanceUnit": "m",
"enableLog": false,
"logLevel": "error",
"x": 560,
"y": 220,
"wires": [["m_int_dbg_process"],["m_int_dbg_influx"],["m_int_dbg_parent"]]
},
{
"id": "m_int_child",
"type": "measurement",
"z": "m_tab_int_1",
"name": "M Child",
"scaling": true,
"i_min": 0,
"i_max": 100,
"i_offset": 0,
"o_min": 0,
"o_max": 10,
"simulator": false,
"smooth_method": "none",
"count": 3,
"supplier": "vendor",
"category": "sensor",
"assetType": "pressure",
"model": "PT-C",
"unit": "bar",
"positionVsParent": "upstream",
"hasDistance": true,
"distance": 5,
"distanceUnit": "m",
"enableLog": false,
"logLevel": "error",
"x": 560,
"y": 360,
"wires": [[],[],[]]
},
{
"id": "m_int_register_child",
"type": "inject",
"z": "m_tab_int_1",
"name": "register child",
"props": [
{"p": "topic", "vt": "str"},
{"p": "payload", "vt": "str"},
{"p": "positionVsParent", "v": "upstream", "vt": "str"}
],
"topic": "registerChild",
"payload": "m_int_child",
"payloadType": "str",
"x": 150,
"y": 180,
"wires": [["m_int_parent"]]
},
{
"id": "m_int_measurement",
"type": "inject",
"z": "m_tab_int_1",
"name": "measurement 55",
"props": [{"p": "topic", "vt": "str"},{"p": "payload", "vt": "num"}],
"topic": "measurement",
"payload": "55",
"payloadType": "num",
"x": 150,
"y": 240,
"wires": [["m_int_parent"]]
},
{
"id": "m_int_dbg_process",
"type": "debug",
"z": "m_tab_int_1",
"name": "M int process",
"active": true,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "true",
"targetType": "full",
"x": 810,
"y": 180,
"wires": []
},
{
"id": "m_int_dbg_influx",
"type": "debug",
"z": "m_tab_int_1",
"name": "M int influx",
"active": true,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "true",
"targetType": "full",
"x": 800,
"y": 220,
"wires": []
},
{
"id": "m_int_dbg_parent",
"type": "debug",
"z": "m_tab_int_1",
"name": "M int parent",
"active": true,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "true",
"targetType": "full",
"x": 800,
"y": 260,
"wires": []
}
]

View File

@@ -4,7 +4,7 @@
"description": "Control module measurement",
"main": "measurement.js",
"scripts": {
"test": "node measurement.js"
"test": "node --test test/basic/*.test.js test/integration/*.test.js test/edge/*.test.js"
},
"repository": {
"type": "git",

21
test/README.md Normal file
View File

@@ -0,0 +1,21 @@
# Measurement Test Suite Layout
This folder follows EVOLV standard node test structure.
## Required folders
- `basic/`
- `integration/`
- `edge/`
- `helpers/`
## Baseline files
- `basic/specific-constructor.basic.test.js`
- `basic/scaling-and-output.basic.test.js`
- `basic/nodeclass-routing.basic.test.js`
- `integration/examples-flows.integration.test.js`
- `integration/measurement-event.integration.test.js`
- `edge/invalid-payload.edge.test.js`
- `edge/outlier-toggle.edge.test.js`
Authoritative mapping for coverage intent lives in:
- `.agents/function-anchors/measurement/EVIDENCE-measurement-tests.md`

0
test/basic/.gitkeep Normal file
View File

View File

@@ -0,0 +1,54 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const NodeClass = require('../../src/nodeClass');
const { makeNodeStub, makeREDStub } = require('../helpers/factories');
test('_attachInputHandler routes known topics to source methods', () => {
const inst = Object.create(NodeClass.prototype);
const node = makeNodeStub();
const calls = [];
inst.node = node;
inst.RED = makeREDStub();
inst.source = {
toggleSimulation() { calls.push('simulator'); },
toggleOutlierDetection() { calls.push('outlierDetection'); },
calibrate() { calls.push('calibrate'); },
set inputValue(v) { calls.push(['measurement', v]); },
};
inst._attachInputHandler();
const onInput = node._handlers.input;
onInput({ topic: 'simulator' }, () => {}, () => {});
onInput({ topic: 'outlierDetection' }, () => {}, () => {});
onInput({ topic: 'calibrate' }, () => {}, () => {});
onInput({ topic: 'measurement', payload: 12.3 }, () => {}, () => {});
assert.deepEqual(calls[0], 'simulator');
assert.deepEqual(calls[1], 'outlierDetection');
assert.deepEqual(calls[2], 'calibrate');
assert.deepEqual(calls[3], ['measurement', 12.3]);
});
test('_registerChild emits delayed registerChild message on output 2', () => {
const inst = Object.create(NodeClass.prototype);
const node = makeNodeStub();
inst.node = node;
inst.config = { functionality: { positionVsParent: 'upstream', distance: 5 } };
const originalSetTimeout = global.setTimeout;
global.setTimeout = (fn) => { fn(); return 1; };
try {
inst._registerChild();
} finally {
global.setTimeout = originalSetTimeout;
}
assert.equal(node._sent.length, 1);
assert.equal(node._sent[0][2].topic, 'registerChild');
assert.equal(node._sent[0][2].positionVsParent, 'upstream');
assert.equal(node._sent[0][2].distance, 5);
});

View File

@@ -0,0 +1,25 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { makeMeasurementInstance } = require('../helpers/factories');
test('calculateInput applies scaling and updates bounded output', () => {
const m = makeMeasurementInstance();
m.calculateInput(50);
const out = m.getOutput();
assert.equal(out.mAbs >= 0 && out.mAbs <= 10, true);
assert.equal(out.mPercent >= 0 && out.mPercent <= 100, true);
});
test('out-of-range input is constrained to abs range', () => {
const m = makeMeasurementInstance({
smoothing: { smoothWindow: 1, smoothMethod: 'none' },
});
m.calculateInput(10000);
const out = m.getOutput();
assert.equal(out.mAbs, 10);
});

View File

@@ -0,0 +1,16 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { makeMeasurementInstance } = require('../helpers/factories');
test('Measurement constructor initializes key defaults and ranges', () => {
const m = makeMeasurementInstance();
assert.equal(m.inputValue, 0);
assert.equal(m.outputAbs, 0);
assert.equal(m.outputPercent, 0);
assert.equal(Array.isArray(m.storedValues), true);
assert.equal(typeof m.measurements, 'object');
assert.equal(m.inputRange, 100);
assert.equal(m.processRange, 10);
});

0
test/edge/.gitkeep Normal file
View File

View File

@@ -0,0 +1,28 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const NodeClass = require('../../src/nodeClass');
const { makeNodeStub, makeREDStub } = require('../helpers/factories');
test('measurement topic ignores non-number payloads (current behavior)', () => {
const inst = Object.create(NodeClass.prototype);
const node = makeNodeStub();
const calls = [];
inst.node = node;
inst.RED = makeREDStub();
inst.source = {
set inputValue(v) { calls.push(v); },
toggleSimulation() {},
toggleOutlierDetection() {},
calibrate() {},
};
inst._attachInputHandler();
const onInput = node._handlers.input;
onInput({ topic: 'measurement', payload: '42' }, () => {}, () => {});
onInput({ topic: 'measurement', payload: { value: 42 } }, () => {}, () => {});
assert.equal(calls.length, 0);
});

View File

@@ -0,0 +1,12 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { makeMeasurementInstance } = require('../helpers/factories');
test('toggleOutlierDetection currently converts config object to boolean (known gap)', () => {
const m = makeMeasurementInstance();
assert.equal(typeof m.config.outlierDetection, 'object');
m.toggleOutlierDetection();
assert.equal(typeof m.config.outlierDetection, 'boolean');
});

0
test/helpers/.gitkeep Normal file
View File

111
test/helpers/factories.js Normal file
View File

@@ -0,0 +1,111 @@
const Measurement = require('../../src/specificClass');
function makeUiConfig(overrides = {}) {
return {
unit: 'bar',
enableLog: false,
logLevel: 'error',
supplier: 'vendor',
category: 'sensor',
assetType: 'pressure',
model: 'PT-1',
scaling: true,
i_min: 0,
i_max: 100,
o_min: 0,
o_max: 10,
i_offset: 0,
count: 5,
smooth_method: 'mean',
simulator: false,
positionVsParent: 'atEquipment',
hasDistance: false,
distance: 0,
...overrides,
};
}
function makeMeasurementConfig(overrides = {}) {
return {
general: {
id: 'm-test-1',
name: 'measurement-test',
unit: 'bar',
logging: { enabled: false, logLevel: 'error' },
},
asset: {
uuid: '',
tagCode: '',
tagNumber: 'PT-001',
supplier: 'vendor',
category: 'sensor',
type: 'pressure',
model: 'PT-1',
unit: 'bar',
},
scaling: {
enabled: true,
inputMin: 0,
inputMax: 100,
absMin: 0,
absMax: 10,
offset: 0,
},
smoothing: {
smoothWindow: 5,
smoothMethod: 'mean',
},
simulation: {
enabled: false,
},
functionality: {
positionVsParent: 'atEquipment',
distance: undefined,
},
...overrides,
};
}
function makeNodeStub() {
const handlers = {};
const sent = [];
const status = [];
const warns = [];
return {
id: 'm-node-1',
source: null,
on(event, cb) { handlers[event] = cb; },
send(msg) { sent.push(msg); },
status(s) { status.push(s); },
warn(w) { warns.push(w); },
_handlers: handlers,
_sent: sent,
_status: status,
_warns: warns,
};
}
function makeREDStub(nodeMap = {}) {
return {
nodes: {
getNode(id) {
return nodeMap[id] || null;
},
createNode() {},
registerType() {},
},
httpAdmin: { get() {}, post() {} },
};
}
function makeMeasurementInstance(overrides = {}) {
return new Measurement(makeMeasurementConfig(overrides));
}
module.exports = {
makeUiConfig,
makeMeasurementConfig,
makeNodeStub,
makeREDStub,
makeMeasurementInstance,
};

View File

View File

@@ -0,0 +1,48 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const EXAMPLES_DIR = path.resolve(__dirname, '../../examples');
function readFlow(file) {
const full = path.join(EXAMPLES_DIR, file);
const parsed = JSON.parse(fs.readFileSync(full, 'utf8'));
assert.equal(Array.isArray(parsed), true);
return parsed;
}
function nodesByType(flow, type) {
return flow.filter((n) => n && n.type === type);
}
function injectByTopic(flow, topic) {
return flow.filter((n) => n && n.type === 'inject' && n.topic === topic);
}
test('examples package contains required files', () => {
for (const name of ['README.md', 'basic.flow.json', 'integration.flow.json', 'edge.flow.json']) {
assert.equal(fs.existsSync(path.join(EXAMPLES_DIR, name)), true, `${name} missing`);
}
});
test('basic flow has measurement node and baseline injects', () => {
const flow = readFlow('basic.flow.json');
assert.equal(nodesByType(flow, 'measurement').length >= 1, true);
assert.equal(injectByTopic(flow, 'measurement').length >= 1, true);
assert.equal(injectByTopic(flow, 'calibrate').length >= 1, true);
});
test('integration flow has two measurement nodes and registerChild example', () => {
const flow = readFlow('integration.flow.json');
assert.equal(nodesByType(flow, 'measurement').length >= 2, true);
assert.equal(injectByTopic(flow, 'registerChild').length >= 1, true);
assert.equal(injectByTopic(flow, 'measurement').length >= 1, true);
});
test('edge flow contains edge-driving injects', () => {
const flow = readFlow('edge.flow.json');
assert.equal(injectByTopic(flow, 'measurement').length >= 1, true);
assert.equal(injectByTopic(flow, 'outlierDetection').length >= 1, true);
assert.equal(injectByTopic(flow, 'doesNotExist').length >= 1, true);
});

View File

@@ -0,0 +1,37 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { makeMeasurementInstance } = require('../helpers/factories');
test('updateOutputAbs emits measurement event with configured type/position', async () => {
const m = makeMeasurementInstance({
asset: {
uuid: '',
tagCode: '',
tagNumber: 'PT-001',
supplier: 'vendor',
category: 'sensor',
type: 'pressure',
model: 'PT-1',
unit: 'bar',
},
functionality: {
positionVsParent: 'upstream',
distance: undefined,
},
smoothing: {
smoothWindow: 1,
smoothMethod: 'none',
},
});
const event = await new Promise((resolve) => {
m.measurements.emitter.once('pressure.measured.upstream', resolve);
m.calculateInput(30);
});
assert.equal(event.type, 'pressure');
assert.equal(event.variant, 'measured');
assert.equal(event.position, 'upstream');
assert.equal(typeof event.value, 'number');
});