#!/usr/bin/env node // Suite de tests GOLDEN du workflow "Victron - Auto Charge Manager V1.2". // Execute le VRAI code du node "Compute Charge Targets" sur un snapshot HA reel // modifie par scenario, avec horloge simulee. // // Ce workflow decide ce que la batterie a le droit de PRENDRE (limites DVCC en // courant et en tension). Il gouvernait donc le plafond a 93 % de SOC corrige // le 06/09, et il n'avait aucun test : il n'etait meme pas suivi dans le depot. // // Usage : // node tests/golden-acm/run.mjs # assertions (exit 1 si echec) // node tests/golden-acm/run.mjs --print # affiche les resumes obtenus // node tests/golden-acm/run.mjs --only=topoff // // REGLE : cette suite DOIT passer avant tout push du workflow // (integree a tools/n8n-push.mjs). import fs from 'fs'; import vm from 'vm'; import path from 'path'; import { fileURLToPath } from 'url'; import { scenarios } from './scenarios.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.resolve(__dirname, '../..'); const WORKFLOW_FILE = path.join(ROOT, 'workflows/victron-auto-charge-manager-v1.2.json'); const PRINT = process.argv.includes('--print'); const only = (process.argv.find((a) => a.startsWith('--only=')) || '').slice(7); const wf = JSON.parse(fs.readFileSync(WORKFLOW_FILE, 'utf8')); const fixture = JSON.parse(fs.readFileSync(path.join(__dirname, 'fixture-snapshot.json'), 'utf8')); const nodeCode = wf.nodes.find((n) => n.name === 'Compute Charge Targets').parameters.jsCode; // Le node lit l'heure par new Date() sans argument ; les autres usages de Date // prennent des arguments et ne doivent PAS etre epingles. function makeFakeDate(fixedMs) { return class FakeDate extends Date { constructor(...a) { if (a.length === 0) super(fixedMs); else super(...a); } static now() { return fixedMs; } }; } function runNode(jsCode, inputJson, fixedMs) { const ctx = { $input: { first: () => ({ json: inputJson }) }, console, Intl, Math, Number, Object, String, JSON, Array, Boolean, RegExp, Date: makeFakeDate(fixedMs), }; vm.createContext(ctx); return vm.runInContext(`(function(){\n${jsCode}\n})()`, ctx)[0].json; } function summarize(out) { const c = out.computed || {}; const d = out.decision || {}; return { mode: d.mode, profil: d.effective_profile, courantA: d.target_current_a, tensionV: d.target_voltage_v, alerte: !!d.critical_alert, publieCourant: !!d.should_publish_current, publieTension: !!d.should_publish_voltage, overrideActif: !!c.override_active, }; } let echecs = 0, joues = 0; for (const sc of scenarios) { if (only && !sc.id.includes(only)) continue; joues++; const fixedMs = new Date(sc.time).getTime(); if (!Number.isFinite(fixedMs)) throw new Error(`${sc.id} : time invalide`); const raw = { ...fixture, ...sc.overrides }; let out; try { out = runNode(nodeCode, { data: JSON.stringify(raw) }, fixedMs); } catch (e) { echecs++; console.log(`💥 ${sc.id} : CRASH — ${e.message}`); continue; } const got = summarize(out); if (PRINT) { console.log(`\n─── ${sc.id} | ${sc.label}`); console.log(' expect:', JSON.stringify(got)); if (out.decision?.short_reason) console.log(' raison:', out.decision.short_reason); continue; } const diffs = []; for (const [k, v] of Object.entries(sc.expect || {})) { if (JSON.stringify(got[k]) !== JSON.stringify(v)) { diffs.push(`${k} : attendu ${JSON.stringify(v)}, obtenu ${JSON.stringify(got[k])}`); } } if (diffs.length) { echecs++; console.log(`❌ ${sc.id} | ${sc.label}`); for (const d of diffs) console.log(` ${d}`); if (out.decision?.short_reason) console.log(` raison: ${out.decision.short_reason}`); } else { console.log(`✅ ${sc.id}`); } } if (!PRINT) { console.log(echecs ? `\n${echecs}/${joues} ÉCHEC(S) — NE PAS POUSSER EN PRODUCTION` : `\nGOLDEN ACM OK — ${joues}/${joues} scénarios conformes`); process.exit(echecs ? 1 : 0); }