Files
EVOLV/scripts/comprehensive-test.js
znetsixe 6a6c04d34b Migrate to new Gitea instance (gitea.wbd-rd.nl)
- 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>
2026-03-04 21:07:04 +01:00

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