L Auto Charge Manager a enfin un filet
Troisieme workflow structurant, et le seul sans tests : c est lui qui decide ce que la batterie a le droit de prendre, donc lui qui plafonnait le parc a 93 %, et il n etait meme pas suivi dans le depot avant ce matin. Vingt scenarios sur le patron de la suite hysteresis, avec le vrai code du node dans une sandbox et un snapshot capture contre Home Assistant plutot qu invente — la lecon du 24/08, quand un fixture portait les valeurs de repli du code au lieu des seuils de production et masquait un bug de bande. Ils suivent l ordre reel des branches et enferment chaque paire dans son contrefactuel : hiver sombre contre hiver ensoleille, ete secours contre ete repos, rattrapage de midi contre midi ou le soleil revient, override actif contre override expire, et le takeover demande alors que le contacteur est ouvert. Le releve reel du maintien a 2 A de cette nuit y figure, ainsi que le topoff a 52,4 V pose ce matin. Cinq mutations, chacune tombant sur un scenario different. Suite cablee au push, bloquante comme les trois autres. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ
This commit is contained in:
113
tests/golden-acm/run.mjs
Normal file
113
tests/golden-acm/run.mjs
Normal file
@@ -0,0 +1,113 @@
|
||||
#!/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);
|
||||
}
|
||||
Reference in New Issue
Block a user