#!/usr/bin/env node // Suite de tests du chantier bilan énergétique. // Exécute le VRAI code des nœuds de src/energy-balance/ dans une sandbox vm, // avec le shim $input de n8n, sur des snapshots HA (réels + cas construits). // // Usage : node tests/energy-balance/run.mjs [--print] [--only=] import fs from 'node:fs'; import path from 'node:path'; import vm from 'node:vm'; import { fileURLToPath } from 'node:url'; import { scenarios as censorshipScenarios } from './scenarios.mjs'; import { profileScenarios, resourceScenarios } from './scenarios-profile.mjs'; import { balanceScenarios } from './scenarios-balance.mjs'; import { shadowScenarios, profilesShadowScenarios, forecastScenarios } from './scenarios-shadow.mjs'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); const PRINT = process.argv.includes('--print'); const ONLY = (process.argv.find(a => a.startsWith('--only=')) || '').split('=')[1]; const codeCache = new Map(); function runNode(nodeName, snapshot) { if (!codeCache.has(nodeName)) { codeCache.set(nodeName, fs.readFileSync(path.join(ROOT, `src/energy-balance/${nodeName}.js`), 'utf8')); } // Certains nœuds sont de la colle n8n et lisent d'autres nœuds via $('Nom'). const upstream = snapshot.__nodes || {}; const sandbox = { $input: { first: () => ({ json: snapshot }) }, $: (name) => { if (!(name in upstream)) throw new Error(`nœud amont non fourni au test : ${name}`); return { first: () => ({ json: upstream[name] }) }; }, console, }; vm.createContext(sandbox); const out = vm.runInContext(`(function(){ ${codeCache.get(nodeName)} })()`, sandbox, { timeout: 15000 }); return nodeName.startsWith('BUILD_HA_CALLS') ? out : out[0].json; } const SUMMARIZE = { CENSORSHIP: o => ({ pvCensored: o.censorship.pvCensored, reasons: o.censorship.reasons.join('|'), sampleExploitable: o.censorship.sampleExploitable, healthOk: o.health.ok, alerts: o.alerts.join('|'), }), DAILY_PROFILES: o => ({ days: o.days, complete: o.complete, hours: o.profile.length, countersReset: o.quality.countersReset, unreliableHours: o.quality.unreliableHours.join(','), csvFitsHelper: o.csv.p80.length <= 255 && o.csv.p50.length <= 255, p50AtHour14: o.profile[14].p50, p80AtHour14: o.profile[14].p80, // Heures 1 à 3 et 8 : sert au scénario d'attribution horaire, où un // compteur épars ne se réveille que la nuit. p50Hours1a3: o.profile.slice(1, 4).map(h => h.p50).join(','), p50AtHour8: o.profile[8].p50, spannedGaps: o.quality.spannedGaps, }), RESOURCE_ESTIMATE: o => ({ usable: o.usable, mode: o.mode, // Quelle courbe a ete retenue, et pour quel jour (correctif du 08/09). alignment: o.source.forecastAlignment, todayExpected: o.todayExpectedRemainingKwh, tomorrowExpected: o.tomorrowExpectedKwh, shapeConfidenceBelow: o.source.shapeConfidence != null && o.source.shapeConfidence < 0.5, // Qualite de forme d'une heure de nuit (03 h) et d'une heure de plein // apres-midi (14 h) : sert au scenario de nuit, ou une heure sans soleil // ne doit pas etre declaree « minoree ». qualityAtHour3: (o.horizon.find(h => h.hour === 3) || {}).shapeQuality, qualityAtHour14: (o.horizon.find(h => h.hour === 14) || {}).shapeQuality, }), BALANCE: o => o.usable ? ({ usable: true, crossingFound: o.crossing.found, crossingHour: o.crossing.hour, energyToCoverKwh: o.coverage.energyToCoverKwh, requiredSocPct: o.coverage.requiredSocPct, gridNeeded: o.coverage.gridNeeded, // Le MANQUE, et pas l'energie a couvrir. C'est lui qui finance la session : // depuis le 18/09 la consigne reseau dimensionne sa part batterie dessus. // Il manquait au resume alors qu'il est desormais consomme en aval. gridEnergyNeededKwh: Math.round(o.coverage.gridEnergyNeededKwh * 100) / 100, prudentRequiredSocPct: o.coverage.prudent.requiredSocPct, deficitTrustworthy: o.confidence.deficitTrustworthy, nextDeficitFound: o.nextDeficit.found, nextDeficitStartHour: o.nextDeficit.found ? o.nextDeficit.startHour : null, nextDeficitEnergyKwh: o.nextDeficit.found ? o.nextDeficit.energyKwh : null, nextDeficitRequiredSocPct: o.nextDeficit.found ? o.nextDeficit.requiredSocAtStartPct : null, storageShortfallKwh: o.nextDeficit.found ? Math.round(o.nextDeficit.storageShortfallKwh * 100) / 100 : null, // Réserve d'orage : niveau lu, activation, et le chiffre qui compte — // ce qu'il faut en batterie pour traverser sans réseau. stormLevel: o.storm.level, stormActive: o.storm.active, stormReserveKwh: o.storm.reserveKwh, stormReserveSocPct: o.storm.reserveSocPct, stormCovered: o.storm.covered, stormShortfallKwh: o.storm.shortfallKwh, // Tenir jusqu'a la prochaine fenetre HC (28/09). hcReachActive: o.hcReach ? o.hcReach.active : false, hcFloorPct: o.hcReach ? o.hcReach.floorPct : null, hcNightFloorPrudentPct: o.hcReach ? o.hcReach.nightFloorPrudentPct : null, // Nuit prevue par la soiree (28/09). nightModelActive: o.nightModel ? o.nightModel.active : false, nightFactor: o.nightModel ? o.nightModel.factor : null, // Maison sur le reseau pendant la fenetre HC de nuit (29/09). nightHcActive: o.nightHcImport ? o.nightHcImport.active : false, nightHcHouseKwh: o.nightHcImport ? o.nightHcImport.houseKwh : null, // Budget voiture de la nuit sans EDF (30/09). nightVehicleBudgetActive: o.nightVehicleBudget ? o.nightVehicleBudget.active : false, nightVehicleBudgetKwh: o.nightVehicleBudget ? o.nightVehicleBudget.kwh : null, // Voiture branchee en HC, depart avant la prochaine HC (28/09). vehicleActive: o.vehicleMidday ? o.vehicleMidday.active : false, vehicleNeedKwh: o.vehicleMidday ? o.vehicleMidday.needKwh : null, vehicleReachableKwh: o.vehicleMidday ? o.vehicleMidday.reachableKwh : null, vehicleSolarKwh: o.vehicleMidday ? o.vehicleMidday.solarKwh : null, vehicleFloorAtDeparturePct: o.vehicleMidday ? o.vehicleMidday.floorAtDeparturePct : null, vehicleGridNeeded: o.vehicleMidday ? o.vehicleMidday.gridNeeded : false, }) : ({ usable: false, missing: o.missing.join(',') }), }; // BUILD_HA_CALLS renvoie une LISTE d'appels, pas un objet unique. SUMMARIZE.BUILD_HA_CALLS = (calls) => { const entities = calls .filter(c => c.json.domain !== 'logbook') .map(c => c.json.data.entity_id || ''); return { callCount: calls.length, outOfScope: entities.filter(e => !/^(input_text|input_number|input_boolean)\.evcs_balance_/.test(e)).join(','), wroteRequiredSoc: entities.includes('input_number.evcs_balance_required_soc'), wroteGridNeeded: entities.includes('input_boolean.evcs_balance_grid_needed'), // Les 5 écritures de la réserve d'orage sont-elles bien émises ET dans le // périmètre ombre ? (outOfScope les attraperait si elles en sortaient). wroteStorm: entities.filter(e => /evcs_balance_storm_/.test(e)).length, hasLogbook: calls.some(c => c.json.domain === 'logbook'), wroteProfiles: entities.includes('input_text.evcs_balance_consumption_p50'), status: (calls.find(c => c.json.data.entity_id === 'input_text.evcs_balance_status') || { json: { data: {} } }).json.data.value, }; }; SUMMARIZE.BUILD_HA_CALLS_PROFILES = SUMMARIZE.BUILD_HA_CALLS; SUMMARIZE.BUILD_HA_CALLS_FORECAST = (calls) => { const base = SUMMARIZE.BUILD_HA_CALLS(calls); const entities = calls.filter(c => c.json.domain !== 'logbook').map(c => c.json.data.entity_id || ''); return { ...base, wroteForecast: entities.includes('input_text.evcs_balance_forecast_today') }; }; SUMMARIZE.BUILD_FORECAST_URL = (o) => ({ urlOk: o.ok, problems: (o.problems || []).join(','), planeCount: o.planeCount ?? null, urlTail: o.url ? o.url.split('/period/')[1].split('/').slice(2).join('/') : null, }); const all = [ ...censorshipScenarios.map(s => ({ node: 'CENSORSHIP', ...s })), ...profileScenarios, ...resourceScenarios, ...balanceScenarios, ...shadowScenarios, ...profilesShadowScenarios, ...forecastScenarios, ]; let failed = 0; let run = 0; for (const sc of all) { if (ONLY && !sc.id.includes(ONLY)) continue; run++; const got = SUMMARIZE[sc.node](runNode(sc.node, sc.snapshot)); if (PRINT) { console.log(`─── [${sc.node}] ${sc.id} | ${sc.label}`); console.log(' ' + JSON.stringify(got)); continue; } const diffs = []; for (const [k, want] of Object.entries(sc.expect)) { if (JSON.stringify(got[k]) !== JSON.stringify(want)) { diffs.push(` ${k}: attendu ${JSON.stringify(want)}, obtenu ${JSON.stringify(got[k])}`); } } if (diffs.length) { failed++; console.log(`❌ ${sc.id} | ${sc.label}`); diffs.forEach(d => console.log(d)); } else { console.log(`✅ ${sc.id}`); } } // Garde-fou (28/09) : tout champ que BALANCE.js lit sur son entree doit etre // produit par PREPARE_BALANCE.js. Les scenarios appellent BALANCE directement : // un champ oublie dans la colle passait tous les tests et rendait une regle // muette en production (heures HC, nuit du 28/09). if (!PRINT && (!ONLY || 'prepare-balance-transmet-tout'.includes(ONLY))) { run++; const src = fs.readFileSync(path.join(ROOT, 'src/energy-balance/BALANCE.js'), 'utf8'); const lus = new Set([...src.matchAll(/(? m[1])); const prep = fs.readFileSync(path.join(ROOT, 'src/energy-balance/PREPARE_BALANCE.js'), 'utf8'); const manquants = [...lus].filter(k => !new RegExp(`^\\s*${k}\\s*:`, 'm').test(prep)); if (manquants.length) { failed++; console.log(`❌ prepare-balance-transmet-tout | champs lus par BALANCE absents de PREPARE_BALANCE : ${manquants.join(', ')}`); } else { console.log(`✅ prepare-balance-transmet-tout (${lus.size} champs)`); } } if (PRINT) process.exit(0); console.log(''); if (failed) { console.log(`${failed} ÉCHEC(S) — NE PAS POUSSER EN PRODUCTION`); process.exit(1); } console.log(`ENERGY BALANCE OK — ${run}/${run} scénarios conformes`);