Files
Tallagrand Thierry c7e665cd5c La protection regardait la maison, pas ce qu on demande au parc
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
2026-09-06 13:50:28 +02:00

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);
}