#!/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); });