R3: suite de tests golden — 16 scénarios, bloquante avant push

- tests/golden/run.mjs : chaîne complète des 5 nodes (code réel) en sandbox vm,
  horloge simulée par scénario, snapshot HA réel comme fixture.
- 16 scénarios attendus figés après revue : nuit HC (P7), solaire, abondance P6,
  provocation P3, gardes SOC, force_grid/force_charge, stop, overload.
- n8n-push.mjs exécute la suite avant tout PUT du workflow EVCS :
  golden rouge = push refusé (--skip-golden en échappatoire consciente).
- Validé par mutation : P7 cassé volontairement -> 2 échecs détectés + push bloqué.
- tools/fetch-snapshot-fixture.mjs : recapture d'un snapshot production.
This commit is contained in:
Tallagrand Thierry
2026-08-07 19:16:03 +02:00
parent 9c3a0a9c6e
commit d1efeac44c
6 changed files with 838 additions and 0 deletions

176
tests/golden/run.mjs Normal file
View File

@@ -0,0 +1,176 @@
#!/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',
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,
};
}
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);
}