#!/usr/bin/env node // Tests de l'EVCS Advisor en RELECTEUR de la journee (08/10/2026) : execute le // VRAI code de BUILD_PROMPT, VALIDATE_PARSE et BUILD_HA_CALLS (sandbox vm) // contre des reponses LLM simulees. Verifie aussi la GARANTIE : BUILD_HA_CALLS // ne touche que les entites advisor et le logbook. // Integre au push (tools/n8n-push.mjs) pour le workflow advisor. import fs from 'fs'; import vm from 'vm'; import path from 'path'; import { fileURLToPath } from 'url'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); const src = (f) => fs.readFileSync(path.join(ROOT, 'src/advisor', f), 'utf8'); const BUILD_PROMPT = src('BUILD_PROMPT.js'); const VALIDATE_PARSE = src('VALIDATE_PARSE.js'); const BUILD_HA_CALLS = src('BUILD_HA_CALLS.js'); // Horloge figee : 08/10/2026 07:35 a Paris. const FIXE = Date.parse('2026-10-08T07:35:00+02:00'); class DateFixe extends Date { constructor(...a) { super(...(a.length ? a : [FIXE])); } static now() { return FIXE; } } function run(code, inputJson, nodeOutputs = {}, D = DateFixe) { const ctx = { $input: { first: () => ({ json: inputJson }) }, $: (name) => ({ first: () => ({ json: nodeOutputs[name] }) }), console, Date: D, Intl, Math, Number, Object, String, JSON, Array, Boolean, Set, Map }; vm.createContext(ctx); return vm.runInContext(`(function(){\n${code}\n})()`, ctx); } // Constats reels du 07-08/10 (sortie de tools/revue-jour.mjs sur la base). const CONSTATS = [ { code: 'batterie_sous_objectif_midi', gravite: 'a_examiner', texte: 'Fin des heures creuses de midi (14:24) : batterie a 35 % pour 43,6 % vises, il manquait 2,6 kWh.', contexte: 'Maintien du courant', etat: "a 13:24 : SOC 33 %, consigne reseau 5198 W, courant de charge max fixe par l'ACM 30 A, mode de nuit sans_edf" }, { code: 'edf_heures_pleines', gravite: 'a_examiner', texte: 'EDF ferme en heures pleines de 18:06 a 01:24 (7 h 18), 8,3 kWh achetes, batterie a 21 % a la fermeture ; ferme par Victron - Grid ON si SoC < 22%.', contexte: 'Conditions de démarrage non réunies' }, { code: 'voiture_bloquee_session_hc', gravite: 'a_examiner', texte: "Voiture branchee mais bloquee de 01:24 a 05:14 (3 h 50) alors qu'EDF etait ferme en heures creuses (etat night_no_grid_hc_lock).", contexte: 'Nuit sans autorisation HC réseau | Nuit EVCS: target=38.8% (BILAN)' }, { code: 'charge_max_midi', gravite: 'info', texte: 'info', contexte: '' }, ]; const snap = (o = {}) => ({ data: JSON.stringify({ advisor_enabled: 'on', revue_jour_date: '2026-10-08', revue_jour_constats: CONSTATS, advisor_revue_date: '2026-10-07', ...o }) }); let failures = 0; const check = (label, cond, detail = '') => { if (cond) console.log(`✅ ${label}`); else { failures++; console.log(`❌ ${label} ${detail}`); } }; // ---------- BUILD_PROMPT ---------- const p = run(BUILD_PROMPT, snap())[0].json; check('PROMPT: actif quand la revue du jour a des points et pas encore de reponse', p.enabled === true); check('PROMPT: seuls les constats a examiner sont transmis (pas les infos)', p.context.constats.length === 3 && !p.context.constats.some((c) => c.code === 'charge_max_midi')); check('PROMPT: chaque constat et son contexte sont dans le message', CONSTATS.slice(0, 3).every((c) => p.messages[1].content.includes(c.texte))); check('PROMPT: < 6000 caracteres', p.messages[0].content.length + p.messages[1].content.length < 6000); check('PROMPT: kill-switch OFF -> inactif', run(BUILD_PROMPT, snap({ advisor_enabled: 'off' }))[0].json.enabled === false); check('PROMPT: deja repondu aujourd hui -> inactif', run(BUILD_PROMPT, snap({ advisor_revue_date: '2026-10-08' }))[0].json.enabled === false); check('PROMPT: revue d hier -> inactif', run(BUILD_PROMPT, snap({ revue_jour_date: '2026-10-07' }))[0].json.enabled === false); check('PROMPT: rien a examiner -> inactif (pas d appel au modele)', run(BUILD_PROMPT, snap({ revue_jour_constats: [CONSTATS[3]] }))[0].json.enabled === false); const D11 = class extends Date { constructor(...a) { super(...(a.length ? a : [Date.parse('2026-10-08T11:00:00+02:00')])); } }; check('PROMPT: pas de relance apres 10 h', run(BUILD_PROMPT, snap(), {}, D11)[0].json.enabled === false); check('PROMPT: l etat des leviers est transmis', /consigne reseau 5198 W/.test(p.messages[1].content) && /ACM 30 A/.test(p.messages[1].content)); // ---------- VALIDATE_PARSE ---------- const ctxNode = { BUILD_PROMPT: p }; const rep = (obj) => ({ model: 'qwen3-coder', choices: [{ message: { content: typeof obj === 'string' ? obj : JSON.stringify(obj) } }] }); const v = (obj) => run(VALIDATE_PARSE, rep(obj), ctxNode)[0].json; const bon = { pistes: [ { code: 'voiture_bloquee_session_hc', piste: 'Le mode « sans EDF » bloquait la voiture alors que la session HC etait engagee pour la batterie : verifier la regle qui garde la voiture bloquee.' }, { code: 'edf_heures_pleines', piste: "L'automatisation « Grid ON si SoC < 22% » a ferme EDF a 21 % : la recharge de midi n'a pas suffi." }, ] }; const o1 = v(bon); check('VALIDATE: reponse valide -> 2 pistes', o1.status === 'ok' && o1.pistes.length === 2 && o1.rejetees === 0, JSON.stringify(o1)); check('VALIDATE: et fences tolerees', v('...\n```json\n' + JSON.stringify(bon) + '\n```').pistes.length === 2); const o2 = v({ pistes: [{ code: 'edf_heures_pleines', piste: 'Il aurait fallu 60 % a 14:24 pour eviter les 8,3 kWh.' }] }); check('VALIDATE: ANTI-INVENTION — chiffre absent du constat (60) -> piste ecartee', o2.pistes.length === 0 && o2.rejetees === 1, JSON.stringify(o2)); const o3 = v({ pistes: [{ code: 'edf_heures_pleines', piste: 'Fermeture a 21 %, 8,3 kWh : revoir la recharge de midi.' }] }); check('VALIDATE: chiffres du constat (21, 8,3) acceptes', o3.pistes.length === 1, JSON.stringify(o3)); check('VALIDATE: chiffres de l etat acceptes (30 A, 5198 W)', v({ pistes: [{ code: 'batterie_sous_objectif_midi', piste: 'ACM a 30 A et consigne tenue a 5198 W.' }] }).pistes.length === 1); check('VALIDATE: code inconnu -> ecarte', v({ pistes: [{ code: 'invente', piste: 'x' }] }).rejetees === 1); check('VALIDATE: un constat info n est pas relu', v({ pistes: [{ code: 'charge_max_midi', piste: 'x' }] }).pistes.length === 0); check('VALIDATE: doublon de code -> un seul garde', v({ pistes: [bon.pistes[0], bon.pistes[0]] }).pistes.length === 1); check('VALIDATE: texte sans JSON -> invalid', v('Je pense que tout va bien.').status === 'invalid'); check('VALIDATE: pistes absent -> invalid', v({ constat: 'x' }).status === 'invalid'); { const o = v({ pistes: [{ code: 'edf_heures_pleines', piste: 'Il aurait fallu 60 % a 14:24.' }, bon.pistes[0]] }); check('VALIDATE: piste ecartee gardee avec son motif (historisation)', o.ecartees.length === 1 && /chiffre absent du constat : 60/.test(o.ecartees[0].motif) && o.pistes.length === 1, JSON.stringify(o.ecartees)); } check('VALIDATE: modele local -> source local', o1.source === 'local'); check('VALIDATE: reponse OpenRouter -> source openrouter', run(VALIDATE_PARSE, { ...rep(bon), id: 'gen-123', provider: 'x' }, ctxNode)[0].json.source === 'openrouter'); check('VALIDATE: LLM hors ligne -> llm_offline', run(VALIDATE_PARSE, { error: 'ECONNREFUSED' }, ctxNode)[0].json.status === 'llm_offline'); // ---------- BUILD_HA_CALLS : garantie de perimetre ---------- const calls = run(BUILD_HA_CALLS, o1, ctxNode).map((i) => i.json); const permis = (c) => c.domain === 'logbook' || c.state_entity === 'sensor.evcs_advisor_revue' || /^input_text\.evcs_advisor_/.test(c.data?.entity_id || ''); check('PERIMETRE: aucune ecriture hors entites advisor / logbook', calls.every(permis), JSON.stringify(calls.filter((c) => !permis(c)))); const capteur = calls.find((c) => c.state_entity === 'sensor.evcs_advisor_revue'); check('PERIMETRE: capteur de la revue publie, date du jour, pistes en attribut', !!capteur && capteur.data.state === '2026-10-08' && capteur.data.attributes.pistes.length === 2); check('PERIMETRE: le role d OpenRouter est affiche', /local de la Spark/.test(capteur?.data.attributes.source_texte || '')); const hl = run(BUILD_HA_CALLS, run(VALIDATE_PARSE, { error: 'x' }, ctxNode)[0].json, ctxNode).map((i) => i.json); check('PERIMETRE: hors ligne -> statut ecrit, aucun capteur (relance au cycle suivant)', !hl.some((c) => c.state_entity) && hl.some((c) => String(c.data?.value || '').startsWith('llm_offline'))); // ---------- Execute node : routage states / services ---------- const wf = JSON.parse(fs.readFileSync(path.join(ROOT, 'workflows/evcs-advisor-shadow-v1.json'), 'utf8')); const url = wf.nodes.find((n) => n.name === 'Execute HA Service Advisor').parameters.url; check('WORKFLOW: un item state_entity part vers /api/states', url.includes("$json.state_entity ? 'http://10.10.10.17:8123/api/states/'")); console.log(failures ? `\n${failures} ÉCHEC(S) — NE PAS POUSSER` : '\nADVISOR OK — tous les tests passent'); process.exit(failures ? 1 : 0);