diff --git a/HANDOFF.md b/HANDOFF.md index e18cda1..55a3485 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -520,3 +520,55 @@ divergence silencieuse entre sourceMode (WINDOWS) et conditions (BCL). --- *Document mis à jour le : 07/08/2026 (~19:10) — R1 déployé, comportement inchangé (A/B), production stable* + +## 13. Session du 07/08/2026 (soir) — R3 : Suite de tests golden + +### Ce qui existe désormais (`tests/golden/`) +- **`run.mjs`** : exécute le VRAI code des nodes en chaîne complète + (INPUT_PARSE → WINDOWS_SOURCEV2 → BATTERY_CLOUD_LOCKS → DECISION_ENGINEV2 + → HA_SERVICE_CALLS_OUTPUT) dans une sandbox vm avec **horloge simulée** + (Date épinglée par scénario) sur un **snapshot HA réel** modifié par overrides. +- **`fixture-snapshot.json`** : snapshot production capturé depuis l'exécution + n8n 975833 (173 champs, re-capturable via `tools/fetch-snapshot-fixture.mjs`). +- **`scenarios.mjs`** : 16 scénarios avec attendus FIGÉS après revue manuelle : + 6 nuit HC (P7 ON/OFF, sous-cible, hard floor, sans véhicule, EDF ON→start), + 4 solaire (matin fort, matin nuageux, abondance P6, provocation P3), + garde SOC midi HC, force_grid, force_charge nuit, stop, overload, débranché. + +### Usage +- `node tests/golden/run.mjs` → 16/16 ✅ ou liste des diffs + exit 1 +- `--print` : affiche les résumés obtenus (pour autoriser une évolution) +- `--json` : sortie machine (utilisée pour figer les attendus) +- `--only=` : filtre par id de scénario + +### Intégration au déploiement (le point clé) +`tools/n8n-push.mjs` exécute la suite AVANT tout PUT du workflow EVCS : +**golden rouge = rien n'est poussé**. Échappatoire consciente : `--skip-golden`. +Évolution volontaire du comportement → mettre à jour les attendus dans +`scenarios.mjs` DANS LE MÊME COMMIT que le patch. + +### Validation par mutation +P7 volontairement cassé (`nightGridHcPreferred = false`) → la suite détecte +2 échecs avec diffs précis (sourceMode, takeover, EDF) et bloque le push ; +restauration → 16/16. Le filet fonctionne. + +### Résumé testé par scénario +profile, sourceMode, state, decision, desiredCharging, currentA, +takeoverNeeded, action EDF ON, service helper takeover. + +### Notes de comportement figées (découvertes en écrivant les attendus) +- SOC 20 la nuit avec maison sur batterie (−14 A) → `battery_current_hard_stop` + (bande lt25 : hard −8 A) + demande réseau simultanée. Protection cohérente. +- `force_charge_now` la nuit (toggle HC ON) → `start_manual_local_min_current` + ET takeover helper ON (session nuit active) : EDF peut venir en renfort. Voulu. +- Garde SOC>35 midi : sourceMode `grid_hc_allowed` mais `idle_wait_conditions` + (le grid HC midi ne s'active que SOC ≤ 35) — comportement épinglé par le test. + +### Prochaines étapes du plan +- R2 : git source de vérité (src/nodes/*.js + assemblage dans n8n-push). +- Agent IA planificateur en mode ombre (après R2). +- Enrichir la suite au fil des incidents : chaque bug corrigé = +1 scénario. + +--- +*Document mis à jour le : 07/08/2026 (~19:20) — R3 en place, 16/16, push bloquant* + diff --git a/tests/golden/fixture-snapshot.json b/tests/golden/fixture-snapshot.json new file mode 100644 index 0000000..f8a6440 --- /dev/null +++ b/tests/golden/fixture-snapshot.json @@ -0,0 +1,175 @@ +{ + "ac_loads_max_safe_w": "9000.0", + "afternoon_battery_max_discharge_a": "unknown", + "afternoon_probe_pv_avg_min_w": "1000.0", + "afternoon_probe_soc_min": "85.0", + "allow_grid_hc_midday": "on", + "allow_grid_hc_night": "on", + "base_load_remaining_kwh": "2.2", + "batt_charge_reserve_25_40_a": "110.0", + "batt_charge_reserve_40_60_a": "70.0", + "batt_charge_reserve_60_80_a": "40.0", + "batt_charge_reserve_80_90_a": "10.0", + "batt_charge_reserve_gt90_a": "5.0", + "batt_charge_reserve_lt25_a": "150.0", + "batt_discharge_hard_25_40_a": "-15.0", + "batt_discharge_hard_40_60_a": "-30.0", + "batt_discharge_hard_60_80_a": "-50.0", + "batt_discharge_hard_80_90_a": "-80.0", + "batt_discharge_hard_gt90_a": "-100.0", + "batt_discharge_hard_lt25_a": "-8.0", + "batt_discharge_soft_25_40_a": "-5.0", + "batt_discharge_soft_40_60_a": "-12.0", + "batt_discharge_soft_60_80_a": "-20.0", + "batt_discharge_soft_80_90_a": "-40.0", + "batt_discharge_soft_gt90_a": "-50.0", + "batt_discharge_soft_lt25_a": "-3.0", + "batt_hard_tol_25_40_sec": "5.0", + "batt_hard_tol_40_60_sec": "10.0", + "batt_hard_tol_60_80_sec": "15.0", + "batt_hard_tol_80_90_sec": "20.0", + "batt_hard_tol_gt90_sec": "20.0", + "batt_hard_tol_lt25_sec": "2.0", + "batt_recovery_25_40_min": "12.0", + "batt_recovery_40_60_min": "10.0", + "batt_recovery_60_80_min": "8.0", + "batt_recovery_80_90_min": "6.0", + "batt_recovery_gt90_min": "6.0", + "batt_recovery_lt25_min": "15.0", + "batt_soft_tol_25_40_min": "2.0", + "batt_soft_tol_40_60_min": "4.0", + "batt_soft_tol_60_80_min": "6.0", + "batt_soft_tol_80_90_min": "10.0", + "batt_soft_tol_gt90_min": "12.0", + "batt_soft_tol_lt25_min": "1.0", + "battery_current_a": "2.799999952316284", + "battery_discharge_hard_a": "-25.0", + "battery_discharge_soft_a": "0.0", + "battery_full_forecast_min_kwh": "4.5", + "battery_full_soc": "90.0", + "battery_neutral_band_a": "0.0", + "battery_reserve_up_a": "8.0", + "blocked_until": "2026-08-07 19:08:30", + "budget_remaining_kwh": "-10.11", + "charge_permission_now": "off", + "cloud_debt_kwh": "0.0", + "cloud_debt_max_kwh": "1.2", + "cloud_debt_started_at": "2026-08-06 19:41:30", + "cloud_tolerance_minutes": "20.0", + "control_latch": "off", + "controller_enabled": "on", + "current_change_cooldown_seconds": "30.0", + "current_max_auto_a": "22.0", + "current_min_a": "6.0", + "current_step_down_a": "2.0", + "current_step_up_a": "1.0", + "day_start_time": "08:30:00", + "day_stop_time": "20:15:00", + "departure_priority_enabled": "on", + "departure_time": "08:50:00", + "end_of_day_soc_guard": "85.0", + "end_of_day_soc_target": "85.0", + "evcs_ac_power_w": "0.0", + "evcs_auto_start_switch": "off", + "evcs_charging_switch": "off", + "evcs_command_current_a": "6", + "evcs_current_a": "0.0", + "evcs_delivery_noise_ema": "0.003", + "evcs_delivery_ratio_ema": "0.001", + "evcs_device_connected": "on", + "evcs_mode_fallback": "unknown", + "evcs_mode_sensor": "Manuel", + "evcs_no_demand_filter_eval_at": "2026-08-07 19:08:30", + "evcs_no_demand_score_ema": "0.002", + "evcs_no_demand_semantic_active": "off", + "evcs_no_demand_since_at": "2026-08-07 18:26:00", + "evcs_priority_manual_mode": "normal", + "evcs_priority_manual_override": "off", + "evcs_priority_manual_reason": "unknown", + "evcs_priority_manual_until": "2026-04-14 00:00:00", + "evcs_set_current_a": "6", + "evcs_status_raw": "0", + "evcs_vehicle_connected": "off", + "evening_battery_max_discharge_a": "-30.0", + "evening_soc_start_min": "85.0", + "evening_soc_stop_min": "80.0", + "evening_start_time": "17:00:00", + "evening_stop_time": "20:30:00", + "flexible_safety_margin_kwh": "0.9", + "force_mode_auto_revert_minutes": "120.0", + "forecast_commit_min_kwh": "10.0", + "forecast_commit_soc_floor": "35.0", + "forecast_mode_enabled": "off", + "grid_battery_hard_a": "-20.0", + "grid_battery_soft_a": "-10.0", + "grid_current_max_a": "20.0", + "grid_power_w": "0", + "grid_state": "off", + "hard_stop_enabled": "on", + "hc_midday_start_time": "12:24:00", + "hc_midday_stop_time": "14:24:00", + "hc_night_start_time": "01:24:00", + "hc_night_stop_time": "07:24:00", + "house_ac_headroom_w": "5000.0", + "house_ac_power_prev_w": "1842.0", + "house_ac_power_w": "1842.0", + "house_spike_grace_minutes": "10.0", + "last_block_reason": "vehicle_not_connected", + "last_current_change_at": "2026-08-07 18:26:30", + "last_signature": "vehicle_not_connected|profile=evening_high_soc|source=solar_only|chg=0|perm=0|l=0|cur=6|block=vehicle_not_connected|stop=0|prio=0|night=0|ntrg=85|nurg=0|vdf=0|vdfs=0.002", + "last_start_at": "2026-08-07 18:26:30", + "last_stop_at": "2026-08-07 18:26:00", + "midday_battery_max_discharge_a": "-40.0", + "midday_probe_pv_avg_min_w": "0.0", + "midday_soc_min": "75.0", + "min_run_minutes": "29.0", + "min_stop_minutes": "0.0", + "morning_battery_max_discharge_a": "-20.0", + "morning_probe_pv_avg_min_w": "1500.0", + "morning_probe_pv_trend_min_w": "-2000.0", + "morning_soc_min": "35.0", + "morning_start_time": "07:30:00", + "morning_stop_time": "11:00:00", + "night_takeover_prev": "off", + "notifications_enabled": "on", + "operating_mode_helper": "evening_high_soc", + "overload_hold_seconds": "30.0", + "overload_step_down_a": "2.0", + "overload_stop_after_seconds": "120.0", + "probe_current_a": "6.0", + "probe_enabled": "on", + "probe_minutes": "3.0", + "probe_retry_minutes": "45.0", + "probe_started_at": "2026-03-31 00:00:00", + "projected_end_soc_after_charge": "35.2", + "pv_hold_min_w": "500.0", + "pv_power_10m_avg_w": "1778", + "pv_power_w": "1851.9319741067588", + "pv_start_min_w": "700.0", + "pv_trend_w": "unknown", + "required_energy_kwh": "10.5", + "soc_comfort_target": "85.0", + "soc_percent": "93.0", + "soc_start_min": "80.0", + "soc_stop_min": "29.0", + "solar_potential_grace_minutes": "12.0", + "step_down_cooldown_seconds": "60.0", + "step_up_cooldown_seconds": "30.0", + "target_current_auto_a": "6.0", + "today_remaining_kwh": "3.486", + "tomorrow_kwh": "57.263", + "vebus_ac_out_l1_current_a": "8.0", + "vebus_ac_out_l1_power_w": "1846", + "vebus_ac_out_l1_voltage_v": "229.75999450683594", + "vebus_dc_current_a": "-38.400001525878906", + "vebus_dc_power_w": "-1842.0", + "vebus_dc_voltage_v": "51.279998779296875", + "vebus_l1_low_battery": "off", + "vebus_l1_overload": "off", + "vebus_low_battery": "off", + "vebus_main_state": "4", + "vebus_overload": "off", + "vebus_state": "9", + "victron_acin1_available": "0", + "victron_evcs_strategy": "auto_solar_plus" +} diff --git a/tests/golden/run.mjs b/tests/golden/run.mjs new file mode 100644 index 0000000..8020a84 --- /dev/null +++ b/tests/golden/run.mjs @@ -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); +} diff --git a/tests/golden/scenarios.mjs b/tests/golden/scenarios.mjs new file mode 100644 index 0000000..f0db8b4 --- /dev/null +++ b/tests/golden/scenarios.mjs @@ -0,0 +1,366 @@ +// R3 — Scénarios golden EVCS. Chaque scénario = instant simulé + overrides du +// snapshot HA réel (tests/golden/fixture-snapshot.json) + résumé attendu. +// Les attendus sont FIGÉS après vérification manuelle : un diff = régression +// (ou évolution volontaire → mettre à jour l'attendu dans le même commit). +// +// Fenêtres (helpers réels) : night_hc 01:24-07:24 | morning 07:30-11:00 | +// midday 12:24-14:24 | evening 17:00-20:30 | sinon outside_window. +// Seuils : hard floor nuit 22 | cible matin forecast fort 35 (tomorrow_kwh 57) | +// soc_start_min 80 | morning_soc_min 35 | midday_soc_min 75. + +const NIGHT = '2026-08-08T01:30:00+02:00'; +const MORNING = '2026-08-08T09:00:00+02:00'; +const MIDDAY = '2026-08-08T13:00:00+02:00'; +const AFTERNOON = '2026-08-08T15:30:00+02:00'; +const EVENING = '2026-08-08T18:00:00+02:00'; + +const VEHICLE_NIGHT = { + evcs_vehicle_connected: 'on', + evcs_device_connected: 'on', + pv_power_w: '0', + pv_power_10m_avg_w: '0', + pv_trend_w: '0', + house_ac_power_w: '700', + vebus_ac_out_l1_power_w: '700', + battery_current_a: '-14', +}; + +export const scenarios = [ + + // ================= NUIT HC (P7 + historique) ================= + { + id: 'night-hc-p7-toggle-on', + label: 'Nuit HC, VW branchée, toggle HC nuit ON, SOC 53, EDF OFF → demande réseau (P7)', + time: NIGHT, + overrides: { ...VEHICLE_NIGHT, soc_percent: '53', allow_grid_hc_night: 'on' }, + expect: { + profile: 'night_hc', + sourceMode: 'grid_hc_allowed', + state: 'night_wait_grid_takeover', + decision: 'hold', + desiredCharging: false, + currentA: 6, + takeoverNeeded: true, + edfOnAction: true, + takeoverHelperService: 'turn_on', + }, + }, + { + id: 'night-hc-p7-edf-on-start', + label: 'Nuit HC, cycle suivant : EDF ON, batterie tenue +2A → démarrage charge HC', + time: NIGHT, + overrides: { + ...VEHICLE_NIGHT, soc_percent: '53', allow_grid_hc_night: 'on', + grid_state: 'on', victron_acin1_available: '1', + battery_current_a: '2', night_takeover_prev: 'on', + }, + expect: { + profile: 'night_hc', + sourceMode: 'grid_hc_allowed', + state: 'start_grid_hc_min_current', + decision: 'start', + desiredCharging: true, + currentA: 6, + takeoverNeeded: true, + edfOnAction: false, + takeoverHelperService: 'turn_on', + }, + }, + { + id: 'night-hc-toggle-off-historique', + label: 'Nuit HC, toggle OFF → comportement historique battery_assist_night (idle)', + time: NIGHT, + overrides: { ...VEHICLE_NIGHT, soc_percent: '53', allow_grid_hc_night: 'off' }, + expect: { + profile: 'night_hc', + sourceMode: 'battery_assist_night', + state: 'idle_wait_conditions', + decision: 'hold', + desiredCharging: false, + currentA: 6, + takeoverNeeded: false, + edfOnAction: false, + takeoverHelperService: 'turn_off', + }, + }, + { + id: 'night-soc-sous-cible', + label: 'Nuit HC, SOC 30 < cible matin 35 → grid_forced', + time: NIGHT, + overrides: { ...VEHICLE_NIGHT, soc_percent: '30', allow_grid_hc_night: 'on' }, + expect: { + profile: 'night_hc', + sourceMode: 'grid_forced', + state: 'night_wait_grid_takeover', + decision: 'hold', + desiredCharging: false, + currentA: 6, + takeoverNeeded: true, + edfOnAction: true, + takeoverHelperService: 'turn_on', + }, + }, + { + id: 'night-hard-floor', + label: 'Nuit HC, SOC 20 ≤ hard floor 22 → hard stop protection batterie + demande réseau', + time: NIGHT, + overrides: { ...VEHICLE_NIGHT, soc_percent: '20', allow_grid_hc_night: 'on' }, + expect: { + profile: 'night_hc', + sourceMode: 'grid_forced', + state: 'battery_current_hard_stop', + decision: 'stop_hard', + desiredCharging: false, + currentA: 6, + takeoverNeeded: true, + edfOnAction: true, + takeoverHelperService: 'turn_on', + }, + }, + { + id: 'night-sans-vehicule', + label: 'Nuit HC sans véhicule → bloqué, takeover relâché', + time: NIGHT, + overrides: { ...VEHICLE_NIGHT, evcs_vehicle_connected: 'off', soc_percent: '53' }, + expect: { + profile: 'night_hc', + sourceMode: 'blocked', + state: 'vehicle_not_connected', + decision: 'stop_not_connected', + desiredCharging: false, + currentA: 6, + takeoverNeeded: false, + edfOnAction: false, + takeoverHelperService: 'turn_off', + }, + }, + + // ================= JOUR SOLAIRE ================= + { + id: 'morning-solaire-fort', + label: 'Matin, PV 4,5 kW, SOC 60, batterie +10A → charge solaire', + time: MORNING, + overrides: { + evcs_vehicle_connected: 'on', evcs_device_connected: 'on', + soc_percent: '60', pv_power_w: '4500', pv_power_10m_avg_w: '4200', + pv_trend_w: '100', battery_current_a: '10', + house_ac_power_w: '900', vebus_ac_out_l1_power_w: '900', + }, + expect: { + profile: 'morning_solar', + sourceMode: 'solar_only', + state: 'start_min_current', + decision: 'start', + desiredCharging: true, + currentA: 6, + takeoverNeeded: false, + edfOnAction: false, + takeoverHelperService: 'turn_off', + }, + }, + { + id: 'morning-nuageux', + label: 'Matin nuageux, PV 300 W, SOC 40 → pas de démarrage', + time: MORNING, + overrides: { + evcs_vehicle_connected: 'on', evcs_device_connected: 'on', + soc_percent: '40', pv_power_w: '300', pv_power_10m_avg_w: '250', + pv_trend_w: '-50', battery_current_a: '-3', + house_ac_power_w: '900', vebus_ac_out_l1_power_w: '900', + }, + expect: { + profile: 'morning_solar', + sourceMode: 'blocked', + state: 'idle_wait_source', + decision: 'hold', + desiredCharging: false, + currentA: 6, + takeoverNeeded: false, + edfOnAction: false, + takeoverHelperService: 'turn_off', + }, + }, + { + id: 'midday-abondance-p6', + label: 'Midi abondance P6 : PV avg 4,5 kW + batterie +25A, SOC 66 < plancher 75 → démarre quand même', + time: MIDDAY, + overrides: { + evcs_vehicle_connected: 'on', evcs_device_connected: 'on', + soc_percent: '66', pv_power_w: '5000', pv_power_10m_avg_w: '4500', + pv_trend_w: '200', battery_current_a: '25', + house_ac_power_w: '1200', vebus_ac_out_l1_power_w: '1200', + }, + expect: { + profile: 'midday_hc_or_solar', + sourceMode: 'solar_only', + state: 'start_min_current', + decision: 'start', + desiredCharging: true, + currentA: 6, + takeoverNeeded: false, + edfOnAction: false, + takeoverHelperService: 'turn_off', + }, + }, + { + id: 'midday-hc-garde-soc', + label: 'Midi sans PV, SOC 50 > 35, HC midi autorisé + EDF ON → grid proposé mais gardé (soc>35)', + time: MIDDAY, + overrides: { + evcs_vehicle_connected: 'on', evcs_device_connected: 'on', + soc_percent: '50', pv_power_w: '100', pv_power_10m_avg_w: '100', + pv_trend_w: '0', battery_current_a: '-4', + grid_state: 'on', victron_acin1_available: '1', + house_ac_power_w: '900', vebus_ac_out_l1_power_w: '900', + }, + expect: { + profile: 'midday_hc_or_solar', + sourceMode: 'grid_hc_allowed', + state: 'idle_wait_conditions', + decision: 'hold', + desiredCharging: false, + currentA: 6, + takeoverNeeded: false, + edfOnAction: false, + takeoverHelperService: 'turn_off', + }, + }, + { + id: 'apresmidi-provocation-p3', + label: 'Après-midi hors fenêtre, SOC 95, PV bridé 300 W (< hold 500) → provocation PV (solar_potential)', + time: AFTERNOON, + overrides: { + evcs_vehicle_connected: 'on', evcs_device_connected: 'on', + soc_percent: '95', pv_power_w: '300', pv_power_10m_avg_w: '300', + pv_trend_w: '0', battery_current_a: '2', + house_ac_power_w: '600', vebus_ac_out_l1_power_w: '600', + }, + expect: { + profile: 'outside_window', + sourceMode: 'solar_potential', + state: 'start_forecast_solar_potential', + decision: 'start', + desiredCharging: true, + currentA: 6, + takeoverNeeded: false, + edfOnAction: false, + takeoverHelperService: 'turn_off', + }, + }, + + // ================= STRATÉGIES MANUELLES ================= + { + id: 'force-grid-jour', + label: 'force_grid_now l\'après-midi, EDF ON, SOC 60 → charge réseau forcée', + time: AFTERNOON, + overrides: { + victron_evcs_strategy: 'force_grid_now', + evcs_vehicle_connected: 'on', evcs_device_connected: 'on', + soc_percent: '60', pv_power_w: '500', pv_power_10m_avg_w: '500', + pv_trend_w: '0', battery_current_a: '2', + grid_state: 'on', victron_acin1_available: '1', + house_ac_power_w: '900', vebus_ac_out_l1_power_w: '900', + }, + expect: { + profile: 'outside_window', + sourceMode: 'grid_forced', + state: 'start_grid_forced_min_current', + decision: 'start', + desiredCharging: true, + currentA: 6, + takeoverNeeded: false, + edfOnAction: false, + takeoverHelperService: 'turn_off', + }, + }, + { + id: 'force-charge-nuit', + label: 'force_charge_now la nuit (toggle HC ON) → démarrage manuel local (non-régression P7)', + time: NIGHT, + overrides: { + ...VEHICLE_NIGHT, victron_evcs_strategy: 'force_charge_now', + soc_percent: '60', allow_grid_hc_night: 'on', + }, + expect: { + profile: 'night_hc', + sourceMode: 'solar_potential', + state: 'start_manual_local_min_current', + decision: 'start', + desiredCharging: true, + currentA: 6, + takeoverNeeded: true, + edfOnAction: false, + takeoverHelperService: 'turn_on', + }, + }, + { + id: 'strategie-stop', + label: 'Stratégie stop en journée → tout bloqué', + time: AFTERNOON, + overrides: { + victron_evcs_strategy: 'stop', + evcs_vehicle_connected: 'on', evcs_device_connected: 'on', + soc_percent: '80', pv_power_w: '3000', pv_power_10m_avg_w: '3000', + battery_current_a: '10', + }, + expect: { + profile: 'outside_window', + sourceMode: 'blocked', + state: 'stopped_user', + decision: 'stop_stop_mode', + desiredCharging: false, + currentA: 6, + takeoverNeeded: false, + edfOnAction: false, + takeoverHelperService: 'turn_off', + }, + }, + + // ================= SÉCURITÉ ================= + { + id: 'securite-overload', + label: 'Charge solaire en cours + surcharge VE.Bus → protection (stop/step-down)', + time: EVENING, + overrides: { + evcs_vehicle_connected: 'on', evcs_device_connected: 'on', + evcs_charging_switch: 'on', control_latch: 'on', charge_permission_now: 'on', + last_start_at: '2026-08-08 17:30:00', + soc_percent: '90', pv_power_w: '3500', pv_power_10m_avg_w: '3400', + battery_current_a: '5', evcs_current_a: '13', evcs_set_current_a: '13', + vebus_overload: 'on', vebus_l1_overload: 'on', + house_ac_power_w: '4800', vebus_ac_out_l1_power_w: '4800', + }, + expect: { + profile: 'evening_high_soc', + sourceMode: 'solar_only', + state: 'vebus_overload_stop_min_current', + decision: 'stop', + desiredCharging: false, + currentA: 6, + takeoverNeeded: false, + edfOnAction: false, + takeoverHelperService: 'turn_off', + }, + }, + { + id: 'vehicule-debranche-jour', + label: 'Après-midi véhicule débranché → stop_not_connected (état de repos)', + time: AFTERNOON, + overrides: { + evcs_vehicle_connected: 'off', evcs_device_connected: 'on', + soc_percent: '93', pv_power_w: '1800', pv_power_10m_avg_w: '1800', + battery_current_a: '3', + }, + expect: { + profile: 'outside_window', + sourceMode: 'solar_only', + state: 'vehicle_not_connected', + decision: 'stop_not_connected', + desiredCharging: false, + currentA: 6, + takeoverNeeded: false, + edfOnAction: false, + takeoverHelperService: 'turn_off', + }, + }, +]; diff --git a/tools/fetch-snapshot-fixture.mjs b/tools/fetch-snapshot-fixture.mjs new file mode 100644 index 0000000..ba3b778 --- /dev/null +++ b/tools/fetch-snapshot-fixture.mjs @@ -0,0 +1,50 @@ +#!/usr/bin/env node +// Capture un snapshot HA RÉEL depuis une exécution n8n récente du workflow EVCS +// → tests/golden/fixture-snapshot.json (base des scénarios golden). +// Usage: node tools/fetch-snapshot-fixture.mjs [executionId] + +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +const WORKFLOW_ID = 'ATswvGfG4KJUls0K'; +const SNAPSHOT_NODE = 'HA Snapshot EVCS Surplus Controller V2.2'; +const OUT = 'tests/golden/fixture-snapshot.json'; + +function loadApiConfig() { + const cfgPath = path.join(os.homedir(), '.config/opencode/opencode.json'); + const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8')); + const env = cfg?.mcp?.['n8n-mcp']?.environment || {}; + return { + url: (env.N8N_API_URL || '').replace(/\/api\/v1\/?$/, ''), + key: env.N8N_API_KEY + }; +} +const { url, key } = loadApiConfig(); +const headers = { 'X-N8N-API-KEY': key }; + +let execId = process.argv[2]; +if (!execId) { + const res = await fetch(`${url}/api/v1/executions?workflowId=${WORKFLOW_ID}&status=success&limit=1`, { headers }); + const list = await res.json(); + execId = list.data?.[0]?.id; + if (!execId) { console.error('Aucune exécution trouvée'); process.exit(1); } +} + +const res = await fetch(`${url}/api/v1/executions/${execId}?includeData=true`, { headers }); +if (!res.ok) { console.error(`GET exécution ${execId}: ${res.status}`); process.exit(1); } +const exec = await res.json(); + +const runData = exec.data?.resultData?.runData; +if (!runData?.[SNAPSHOT_NODE]) { + console.error(`Node "${SNAPSHOT_NODE}" absent des données. Nodes dispo: ${Object.keys(runData || {}).join(', ')}`); + process.exit(1); +} +const items = runData[SNAPSHOT_NODE][0]?.data?.main?.[0]; +if (!items?.length) { console.error('Pas de données de sortie sur le node snapshot'); process.exit(1); } + +const snapshot = items[0].json; +fs.mkdirSync(path.dirname(OUT), { recursive: true }); +fs.writeFileSync(OUT, JSON.stringify(snapshot, null, 2) + '\n'); +console.log(`Fixture écrite: ${OUT} (exécution ${execId}, ${Object.keys(snapshot).length} champs)`); +console.log(`Champs: ${Object.keys(snapshot).sort().join(', ')}`); diff --git a/tools/n8n-push.mjs b/tools/n8n-push.mjs index 7fc8b73..01cd3d5 100644 --- a/tools/n8n-push.mjs +++ b/tools/n8n-push.mjs @@ -86,6 +86,25 @@ if (syntaxErrors) { } console.log('Syntaxe OK sur tous les nodes jsCode.'); +// 3bis. Suite golden (R3) : obligatoire pour le workflow EVCS, sauf --skip-golden +const isEvcsWorkflow = path.basename(INPUT) === 'evcs-surplus-controller-v2.6.json'; +const skipGolden = process.argv.includes('--skip-golden'); +if (isEvcsWorkflow && fs.existsSync('tests/golden/run.mjs')) { + if (skipGolden) { + console.log('⚠️ Suite golden SAUTÉE (--skip-golden) — à n\'utiliser qu\'en connaissance de cause.'); + } else { + console.log('Exécution de la suite golden (tests/golden/run.mjs)...'); + const { spawnSync } = await import('child_process'); + const g = spawnSync('node', ['tests/golden/run.mjs'], { stdio: 'inherit' }); + if (g.status !== 0) { + console.error('ABANDON: la suite golden échoue. Rien n\'a été poussé.'); + console.error('(Évolution volontaire du comportement ? Mettre à jour les attendus'); + console.error(' dans tests/golden/scenarios.mjs dans le même commit, puis re-pousser.)'); + process.exit(1); + } + } +} + // 4. PUT const putRes = await fetch(`${url}/api/v1/workflows/${WORKFLOW_ID}`, { method: 'PUT',