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>
This commit is contained in:
294
scripts/comprehensive-test.js
Normal file
294
scripts/comprehensive-test.js
Normal file
@@ -0,0 +1,294 @@
|
||||
#!/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);
|
||||
});
|
||||
Reference in New Issue
Block a user