#!/usr/bin/env node // R3 — Suite de tests GOLDEN du workflow EVCS Surplus Controller V2.6. // Exécute le VRAI code des nodes (chaîne complète INPUT_PARSE → WINDOWS_SOURCEV2 // → BATTERY_CLOUD_LOCKS → DECISION_ENGINEV2 → HA_SERVICE_CALLS_OUTPUT → Build) // sur un snapshot HA réel modifié par scénario, avec horloge simulée. // // Usage: // node tests/golden/run.mjs # assertions (exit 1 si échec) // node tests/golden/run.mjs --print # affiche les résumés (pour figer les attendus) // node tests/golden/run.mjs --only=night-hc # filtre par label // // RÈGLE : cette suite DOIT passer avant tout push (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/evcs-surplus-controller-v2.6.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 CHAIN = [ 'INPUT_PARSE', 'WINDOWS_SOURCEV2', 'BATTERY_CLOUD_LOCKS', 'DECISION_ENGINEV2', 'HA_SERVICE_CALLS_OUTPUT', ]; const nodeCode = Object.fromEntries( CHAIN.map(n => [n, wf.nodes.find(x => x.name === n).parameters.jsCode]) ); // Horloge simulée : Date épinglée à l'instant du scénario dans la sandbox. 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; } // Overrides par défaut : timers neutres relatifs à l'instant du scénario // (min_stop écoulé, pas de blocage anti-cliquetis, filtre no-demand au repos). function stamp(ms) { const p = new Intl.DateTimeFormat('fr-FR', { timeZone: 'Europe/Paris', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, }).formatToParts(new Date(ms)); const g = t => p.find(x => x.type === t).value; return `${g('year')}-${g('month')}-${g('day')} ${g('hour')}:${g('minute')}:${g('second')}`; } function baseOverrides(fixedMs) { return { last_start_at: stamp(fixedMs - 2 * 3600000), last_stop_at: stamp(fixedMs - 2 * 3600000 + 30000), last_current_change_at: stamp(fixedMs - 2 * 3600000), blocked_until: stamp(fixedMs - 3600000), last_block_reason: 'none', evcs_no_demand_filter_eval_at: stamp(fixedMs - 30000), evcs_no_demand_semantic_active: 'off', evcs_delivery_ratio_ema: '0.9', evcs_delivery_noise_ema: '0.01', evcs_no_demand_score_ema: '0.0', probe_started_at: '', cloud_debt_kwh: '0.0', night_takeover_prev: 'off', prev_surplus_state: 'idle_wait_conditions', grid_state: 'off', victron_acin1_available: '0', evcs_charging_switch: 'off', control_latch: 'off', charge_permission_now: 'off', controller_enabled: 'on', vebus_overload: 'off', vebus_l1_overload: 'off', vebus_low_battery: 'off', vebus_l1_low_battery: 'off', }; } function summarize(out) { const c = out.computed || {}; const d = out.decision || {}; const calls = out.ha_service_calls || {}; return { profile: c.profile, sourceMode: c.source_mode, state: d.state, decision: d.current_decision, desiredCharging: d.desired_charging, currentA: d.desired_current_a, takeoverNeeded: !!c.night_grid_takeover_needed, edfOnAction: !!calls.edf_general_ok_on?.execute, takeoverHelperService: calls.helper_night_grid_takeover_needed?.service || null, // Session reseau unique (03/09/2026) : null = aucune ecriture ce cycle. sessionCommittedService: calls.helper_night_grid_session_committed?.execute ? calls.helper_night_grid_session_committed.service : null, // Consigne de puissance reseau ecrite ce cycle (null = aucune ecriture). gridSetpointW: calls.grid_power_setpoint?.execute ? calls.grid_power_setpoint.data.value : null, }; } 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, ...baseOverrides(fixedMs), ...sc.overrides }; let payload = { data: JSON.stringify(raw) }; try { for (const nodeName of CHAIN) payload = runNode(nodeCode[nodeName], payload, fixedMs); } catch (e) { failures++; console.log(`💥 ${sc.id} : CRASH dans la chaîne — ${e.message}`); continue; } const got = summarize(payload); if (JSON_OUT) { jsonResults[sc.id] = got; continue; } if (PRINT) { console.log(`\n─── ${sc.id} | ${sc.label}`); console.log(' expect:', JSON.stringify(got)); if (payload.decision?.short_reason) console.log(' raison:', payload.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 (payload.decision?.short_reason) console.log(` raison: ${payload.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 OK — ${ran}/${ran} scénarios conformes`); process.exit(failures ? 1 : 0); }