#!/usr/bin/env node // Suite de tests GOLDEN du workflow "Victron - Battery / Grid Hysteresis Fix V1". // Exécute le VRAI code du node "Compute Grid Hysteresis" sur un snapshot HA réel // modifié par scénario, avec horloge simulée. // // Usage: // node tests/golden-hysteresis/run.mjs # assertions (exit 1 si échec) // node tests/golden-hysteresis/run.mjs --print # affiche les résumés (pour figer les attendus) // node tests/golden-hysteresis/run.mjs --only=incident # filtre par id // // RÈGLE : cette suite DOIT passer avant tout push du workflow hystérésis // (intégrée à 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-grid-hysteresis-v1.json'); const FIXTURE_FILE = path.join(__dirname, 'fixture-snapshot.json'); const PRINT = process.argv.includes('--print'); const JSON_OUT = process.argv.includes('--json'); 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(FIXTURE_FILE, 'utf8')); const nodeCode = wf.nodes.find(n => n.name === 'Compute Grid Hysteresis').parameters.jsCode; // Horloge simulée : Date épinglée à l'instant du scénario dans la sandbox. // Le node utilise new Date() (zéro argument) pour "maintenant" et // new Date(y, mo-1, d, h, mi, se) (multi-arguments) pour reparser last_on_at // /last_off_at — seuls les appels zéro-argument doivent être épinglés. function makeFakeDate(fixedMs) { return class FakeDate extends Date { constructor(...args) { if (args.length === 0) super(fixedMs); else super(...args); } static now() { return fixedMs; } }; } function runNode(jsCode, inputJson, fixedMs) { const ctx = { $input: { first: () => ({ json: inputJson }) }, console, Intl, Math, Number, Object, String, JSON, Array, Boolean, Date: makeFakeDate(fixedMs), }; vm.createContext(ctx); const out = vm.runInContext(`(function(){\n${jsCode}\n})()`, ctx); return out[0].json; } function summarize(out) { const c = out.computed || {}; const d = out.decision || {}; return { state: d.state, gridAction: d.grid_action, latchAction: d.latch_action, shouldNotify: !!d.should_notify, evcsGridOverride: !!c.evcs_grid_override, }; } const jsonResults = {}; let failures = 0, ran = 0; for (const sc of scenarios) { if (only && !sc.id.includes(only)) continue; ran++; const fixedMs = new Date(sc.time).getTime(); if (!Number.isFinite(fixedMs)) throw new Error(`${sc.id}: time invalide`); const raw = { ...fixture, ...sc.overrides }; const payload = { data: JSON.stringify(raw) }; let out; try { out = runNode(nodeCode, payload, fixedMs); } catch (e) { failures++; console.log(`💥 ${sc.id} : CRASH — ${e.message}`); continue; } const got = summarize(out); if (JSON_OUT) { jsonResults[sc.id] = got; continue; } 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) { failures++; 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 (JSON_OUT) { console.log(JSON.stringify(jsonResults, null, 2)); process.exit(0); } if (!PRINT) { console.log(failures ? `\n${failures}/${ran} ÉCHEC(S) — NE PAS POUSSER EN PRODUCTION` : `\nGOLDEN HYSTERESIS OK — ${ran}/${ran} scénarios conformes`); process.exit(failures ? 1 : 0); }