Deux fermetures ce dimanche midi. Mesurees a l instant de la decision, contacteur encore ouvert donc production non censuree : maison 4025 et 4981 W, mais parc reellement sollicite 1132 et 772 W pour un plafond de 5100. La decharge batterie mesuree aux memes instants — 1345 et 1223 W — colle a maison moins PV, pas a la maison brute. Le solaire couvrait la cuisson ; la regle a ferme le contacteur pour rien, 0,50 kWh importes en plein soleil. Elle n avait bien fonctionne hier soir que parce qu il n y avait plus de PV. Les deux seuils portent desormais sur max(0, maison - PV). Contacteur ouvert cette grandeur EST la decharge batterie ; ferme, un bridage du MPPT la fait surestimer, donc l erreur pousse a rester ferme et jamais a relacher tot. Golden 20 -> 24, dont les deux releves de midi et le contrefactuel du nuage. Le test de mutation a montre que la premiere serie ne couvrait que le seuil de fermeture : il a fallu ecrire le cas du soleil revenu pour qu elle morde. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ
134 lines
4.4 KiB
JavaScript
134 lines
4.4 KiB
JavaScript
#!/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,
|
|
plafondDechargeW: c.plafond_decharge_w,
|
|
demandeParcW: c.demande_parc_w,
|
|
dwellMinutes: c.dwell_minutes,
|
|
dwellEcoule: c.dwell_ecoule,
|
|
reserveDejaCondamnee: !!c.reserve_deja_condamnee,
|
|
setChargeHighLastAt: !!d.set_charge_high_last_at,
|
|
};
|
|
}
|
|
|
|
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);
|
|
}
|