- Update all submodule URLs from gitea.centraal.wbd-rd.nl to gitea.wbd-rd.nl - Add settler as proper submodule in .gitmodules - Add agent skills, function anchors, decisions, and improvements - Add Docker configuration and scripts - Add manuals and third_party docs - Update .gitignore with secrets and build artifacts - Remove stale .tgz build artifact Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
295 lines
12 KiB
JavaScript
295 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Comprehensive WWTP Demo Test Suite
|
|
*
|
|
* Tests:
|
|
* 1. Deploy succeeds
|
|
* 2. All nodes healthy (no errors)
|
|
* 3. PS volumes above safety threshold after calibration
|
|
* 4. q_in flowing to all PSs (volume rising)
|
|
* 5. Measurement simulators producing values
|
|
* 6. MGC pressure handling working
|
|
* 7. No persistent safety triggers
|
|
* 8. Level-based control (PS West) stays idle at low level
|
|
* 9. Flow-based control (PS North) responds to flow
|
|
* 10. PS output format correct
|
|
*/
|
|
|
|
const http = require('http');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { execSync } = require('child_process');
|
|
|
|
const NR_URL = 'http://localhost:1880';
|
|
const FLOW_FILE = path.join(__dirname, '..', 'docker', 'demo-flow.json');
|
|
|
|
let passed = 0;
|
|
let failed = 0;
|
|
let warnings = 0;
|
|
|
|
function test(name, condition, detail) {
|
|
if (condition) {
|
|
console.log(` ✅ PASS: ${name}${detail ? ' — ' + detail : ''}`);
|
|
passed++;
|
|
} else {
|
|
console.log(` ❌ FAIL: ${name}${detail ? ' — ' + detail : ''}`);
|
|
failed++;
|
|
}
|
|
}
|
|
|
|
function warn(name, detail) {
|
|
console.log(` ⚠️ WARN: ${name}${detail ? ' — ' + detail : ''}`);
|
|
warnings++;
|
|
}
|
|
|
|
function httpReq(method, urlPath, body) {
|
|
return new Promise((resolve, reject) => {
|
|
const parsed = new URL(NR_URL + urlPath);
|
|
const opts = {
|
|
hostname: parsed.hostname,
|
|
port: parsed.port,
|
|
path: parsed.pathname,
|
|
method,
|
|
headers: { 'Content-Type': 'application/json', 'Node-RED-Deployment-Type': 'full' },
|
|
};
|
|
if (body) opts.headers['Content-Length'] = Buffer.byteLength(JSON.stringify(body));
|
|
const req = http.request(opts, (res) => {
|
|
const chunks = [];
|
|
res.on('data', (c) => chunks.push(c));
|
|
res.on('end', () => resolve({ status: res.statusCode, body: Buffer.concat(chunks).toString() }));
|
|
});
|
|
req.on('error', reject);
|
|
if (body) req.write(JSON.stringify(body));
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
function getLogs(since) {
|
|
try {
|
|
return execSync(`docker logs evolv-nodered --since ${since} 2>&1`, {
|
|
encoding: 'utf8', timeout: 5000,
|
|
});
|
|
} catch (e) { return ''; }
|
|
}
|
|
|
|
function fetchJSON(url) {
|
|
return new Promise((resolve, reject) => {
|
|
http.get(url, (res) => {
|
|
const chunks = [];
|
|
res.on('data', (c) => chunks.push(c));
|
|
res.on('end', () => {
|
|
try { resolve(JSON.parse(Buffer.concat(chunks))); }
|
|
catch (e) { reject(e); }
|
|
});
|
|
}).on('error', reject);
|
|
});
|
|
}
|
|
|
|
(async () => {
|
|
console.log('═══════════════════════════════════════');
|
|
console.log(' WWTP Demo Flow — Comprehensive Test');
|
|
console.log('═══════════════════════════════════════\n');
|
|
|
|
// ==========================================================
|
|
console.log('1. DEPLOYMENT');
|
|
console.log('─────────────');
|
|
|
|
const flow = JSON.parse(fs.readFileSync(FLOW_FILE, 'utf8'));
|
|
test('Flow file loads', flow.length > 0, `${flow.length} nodes`);
|
|
|
|
const deployTime = new Date().toISOString();
|
|
const res = await httpReq('POST', '/flows', flow);
|
|
test('Deploy succeeds', res.status === 204 || res.status === 200, `HTTP ${res.status}`);
|
|
|
|
// Wait for init + calibration
|
|
console.log(' Waiting 5s for initialization...');
|
|
await new Promise((r) => setTimeout(r, 5000));
|
|
|
|
// Check for errors in logs
|
|
const initLogs = getLogs(deployTime);
|
|
const initErrors = initLogs.split('\n').filter((l) => l.includes('[ERROR]') || l.includes('Error'));
|
|
test('No initialization errors', initErrors.length === 0,
|
|
initErrors.length > 0 ? initErrors.slice(0, 3).join('; ') : 'clean');
|
|
|
|
// ==========================================================
|
|
console.log('\n2. NODE INVENTORY');
|
|
console.log('─────────────────');
|
|
|
|
const flows = await fetchJSON(NR_URL + '/flows');
|
|
const processTabs = ['demo_tab_wwtp', 'demo_tab_ps_west', 'demo_tab_ps_north', 'demo_tab_ps_south', 'demo_tab_treatment'];
|
|
const wwtp = flows.filter((n) => processTabs.includes(n.z));
|
|
|
|
const byType = {};
|
|
wwtp.forEach((n) => {
|
|
if (!n.type || n.type === 'tab' || n.type === 'comment') return;
|
|
byType[n.type] = (byType[n.type] || 0) + 1;
|
|
});
|
|
|
|
test('Has pumping stations', (byType['pumpingStation'] || 0) === 3, `${byType['pumpingStation'] || 0} PS nodes`);
|
|
test('Has rotating machines', (byType['rotatingMachine'] || 0) === 5, `${byType['rotatingMachine'] || 0} pumps`);
|
|
test('Has measurements', (byType['measurement'] || 0) >= 15, `${byType['measurement'] || 0} measurement nodes`);
|
|
test('Has reactor', (byType['reactor'] || 0) === 1, `${byType['reactor'] || 0} reactor`);
|
|
test('Has machineGroupControl', (byType['machineGroupControl'] || 0) >= 1, `${byType['machineGroupControl'] || 0} MGC`);
|
|
test('Has inject nodes', (byType['inject'] || 0) >= 10, `${byType['inject'] || 0} injects`);
|
|
|
|
console.log(` Node types: ${JSON.stringify(byType)}`);
|
|
|
|
// ==========================================================
|
|
console.log('\n3. PS CONFIGURATION');
|
|
console.log('───────────────────');
|
|
|
|
const pss = flows.filter((n) => n.type === 'pumpingStation');
|
|
pss.forEach((ps) => {
|
|
const vol = Number(ps.basinVolume);
|
|
const h = Number(ps.basinHeight);
|
|
const hOut = Number(ps.heightOutlet);
|
|
const sa = vol / h;
|
|
const minVol = hOut * sa;
|
|
test(`${ps.name} basin config valid`, vol > 0 && h > 0 && hOut >= 0, `vol=${vol} h=${h} hOut=${hOut}`);
|
|
test(`${ps.name} has safety enabled`, ps.enableDryRunProtection === true || ps.enableDryRunProtection === 'true');
|
|
});
|
|
|
|
// Check calibration nodes exist
|
|
const calibNodes = flows.filter((n) => n.id && n.id.startsWith('demo_inj_calib_'));
|
|
test('Calibration inject nodes exist', calibNodes.length === 3, `${calibNodes.length} calibration nodes`);
|
|
|
|
// ==========================================================
|
|
console.log('\n4. MEASUREMENT SIMULATORS');
|
|
console.log('─────────────────────────');
|
|
|
|
const measurements = flows.filter((n) => n.type === 'measurement' && processTabs.includes(n.z));
|
|
const simEnabled = measurements.filter((n) => n.simulator === true || n.simulator === 'true');
|
|
test('Measurement simulators enabled', simEnabled.length >= 10, `${simEnabled.length} of ${measurements.length} have sim=true`);
|
|
|
|
// List measurement nodes
|
|
measurements.forEach((m) => {
|
|
const sim = m.simulator === true || m.simulator === 'true';
|
|
const range = `[${m.o_min}-${m.o_max}] ${m.unit}`;
|
|
if (!sim && !m.id.includes('level') && !m.id.includes('pt_')) {
|
|
warn(`${m.name || m.id} sim=${sim}`, `range ${range}`);
|
|
}
|
|
});
|
|
|
|
// ==========================================================
|
|
console.log('\n5. PUMP CONFIGURATION');
|
|
console.log('─────────────────────');
|
|
|
|
const pumps = flows.filter((n) => n.type === 'rotatingMachine' && processTabs.includes(n.z));
|
|
pumps.forEach((p) => {
|
|
test(`${p.name} has model`, !!p.model, p.model);
|
|
test(`${p.name} supplier lowercase`, p.supplier === 'hidrostal', `supplier="${p.supplier}"`);
|
|
});
|
|
|
|
// ==========================================================
|
|
console.log('\n6. PRESSURE MEASUREMENTS');
|
|
console.log('────────────────────────');
|
|
|
|
const pts = flows.filter((n) => n.type === 'measurement' && n.id && n.id.includes('_pt_'));
|
|
test('6 pressure transmitters', pts.length === 6, `found ${pts.length}`);
|
|
|
|
pts.forEach((pt) => {
|
|
const range = `${pt.o_min}-${pt.o_max} ${pt.unit}`;
|
|
const sim = pt.simulator === true || pt.simulator === 'true';
|
|
const pos = pt.positionVsParent;
|
|
test(`${pt.name} valid`, pt.assetType === 'pressure', `pos=${pos} sim=${sim} range=${range}`);
|
|
|
|
// Check reasonable pressure ranges (not 0-5000)
|
|
if (pos === 'downstream' || pos === 'Downstream') {
|
|
test(`${pt.name} realistic range`, Number(pt.o_max) <= 2000, `o_max=${pt.o_max} (should be <=2000)`);
|
|
}
|
|
});
|
|
|
|
// ==========================================================
|
|
console.log('\n7. RUNTIME BEHAVIOR (30s observation)');
|
|
console.log('─────────────────────────────────────');
|
|
|
|
const obsStart = new Date().toISOString();
|
|
|
|
// Wait 30 seconds and observe
|
|
console.log(' Observing for 30 seconds...');
|
|
await new Promise((r) => setTimeout(r, 30000));
|
|
|
|
const obsLogs = getLogs(obsStart);
|
|
const obsLines = obsLogs.split('\n');
|
|
|
|
// Count message types
|
|
const safetyLines = obsLines.filter((l) => l.includes('Safe guard'));
|
|
const errorLines = obsLines.filter((l) => l.includes('[ERROR]'));
|
|
const monitorLines = obsLines.filter((l) => l.includes('[function:Monitor'));
|
|
|
|
test('No safety triggers in 30s', safetyLines.length === 0, `${safetyLines.length} triggers`);
|
|
test('No errors in 30s', errorLines.length === 0,
|
|
errorLines.length > 0 ? errorLines[0].substring(0, 100) : 'clean');
|
|
test('Monitor nodes producing data', monitorLines.length > 0, `${monitorLines.length} monitor lines`);
|
|
|
|
// Parse monitoring data
|
|
if (monitorLines.length > 0) {
|
|
console.log('\n Monitor data:');
|
|
monitorLines.forEach((l) => {
|
|
const clean = l.replace(/^\[WARN\] -> /, ' ');
|
|
console.log(' ' + clean.trim().substring(0, 150));
|
|
});
|
|
|
|
// Check volume per PS
|
|
const psVolumes = {};
|
|
monitorLines.forEach((l) => {
|
|
const psMatch = l.match(/Monitor (PS \w+)/);
|
|
const volMatch = l.match(/vol=([\d.]+)m3/);
|
|
if (psMatch && volMatch) {
|
|
const ps = psMatch[1];
|
|
if (!psVolumes[ps]) psVolumes[ps] = [];
|
|
psVolumes[ps].push(parseFloat(volMatch[1]));
|
|
}
|
|
});
|
|
|
|
Object.entries(psVolumes).forEach(([ps, vols]) => {
|
|
const first = vols[0];
|
|
const last = vols[vols.length - 1];
|
|
test(`${ps} volume above 0`, first > 0, `vol=${first.toFixed(1)} m3`);
|
|
test(`${ps} volume reasonable`, first < 1000, `vol=${first.toFixed(1)} m3`);
|
|
if (vols.length >= 2) {
|
|
const trend = last - first;
|
|
test(`${ps} volume stable/rising`, trend >= -0.5, `${first.toFixed(1)} → ${last.toFixed(1)} m3 (${trend >= 0 ? '+' : ''}${trend.toFixed(2)})`);
|
|
}
|
|
});
|
|
} else {
|
|
warn('No monitor data', 'monitoring function nodes may not have fired yet');
|
|
}
|
|
|
|
// ==========================================================
|
|
console.log('\n8. WIRING INTEGRITY');
|
|
console.log('───────────────────');
|
|
|
|
// Check all PS have q_in inject
|
|
pss.forEach((ps) => {
|
|
const qinFn = flows.find((n) => n.wires && n.wires.flat && n.wires.flat().includes(ps.id) && n.type === 'function');
|
|
test(`${ps.name} has q_in source`, !!qinFn, qinFn ? qinFn.name : 'none');
|
|
});
|
|
|
|
// Check all pumps have pressure measurements (RAS pump has flow sensor instead)
|
|
pumps.forEach((p) => {
|
|
const childSensors = flows.filter((n) => n.type === 'measurement' && n.wires && n.wires[2] && n.wires[2].includes(p.id));
|
|
const isRAS = p.id === 'demo_pump_ras';
|
|
const minSensors = isRAS ? 1 : 2;
|
|
test(`${p.name} has ${isRAS ? 'sensors' : 'pressure PTs'}`, childSensors.length >= minSensors,
|
|
`${childSensors.length} ${isRAS ? 'sensors' : 'PTs'} (${childSensors.map((pt) => pt.positionVsParent).join(', ')})`);
|
|
});
|
|
|
|
// ==========================================================
|
|
console.log('\n═══════════════════════════════════════');
|
|
console.log(` Results: ${passed} passed, ${failed} failed, ${warnings} warnings`);
|
|
console.log('═══════════════════════════════════════');
|
|
|
|
if (failed > 0) {
|
|
console.log('\n ❌ SOME TESTS FAILED');
|
|
process.exit(1);
|
|
} else if (warnings > 0) {
|
|
console.log('\n ⚠️ ALL TESTS PASSED (with warnings)');
|
|
} else {
|
|
console.log('\n ✅ ALL TESTS PASSED');
|
|
}
|
|
})().catch((err) => {
|
|
console.error('Test suite failed:', err);
|
|
process.exit(1);
|
|
});
|