// Tests du kilometrage (04/10/2026). // node tests/kilometrage/run.mjs -> fonctions pures // node tests/kilometrage/run.mjs --vues -> vues SQL, dans une transaction // ANNULEE (a lancer dans le // conteneur d'historisation) import { lireReleves, ajouterReleve, creerSchemaKm, ingererJeep, JEEP } from '../../tools/kilometrage.mjs'; let ok = 0, ko = 0; const eq = (nom, a, b) => { const sa = JSON.stringify(a), sb = JSON.stringify(b); if (sa === sb) { ok++; } else { ko++; console.log(`ECHEC ${nom}\n obtenu ${sa}\n attendu ${sb}`); } }; const proche = (nom, a, b, tol = 1e-6) => eq(nom, Math.abs(Number(a) - b) <= tol ? b : Number(a), b); // --- lireReleves const T = 1759515246; eq('vide', lireReleves('').releves, []); eq('unknown', lireReleves('unknown').releves, []); eq('json illisible', lireReleves('[x').rejets, ['JSON illisible']); const r1 = lireReleves(JSON.stringify([['jeep', 112694, 54, T, 'w'], ['ZERO', 16469, null, T, 'w']])); eq('deux releves', r1.releves.map(r => [r.vehicule, r.km, r.niveau, r.ts.getTime() / 1000]), [['JEEP', 112694, 54, T], ['ZERO', 16469, null, T]]); eq('km decimal refuse', lireReleves(JSON.stringify([['VW', 1.5, null, T, 'w']])).rejets.length, 1); eq('niveau 150 refuse', lireReleves(JSON.stringify([['VW', 1000, 150, T, 'w']])).rejets.length, 1); eq('horodatage absurde refuse', lireReleves(JSON.stringify([['VW', 1000, null, 12, 'w']])).rejets.length, 1); // --- ajouterReleve : garde les derniers, tient dans 255 caracteres let b = ''; for (let i = 0; i < 10; i++) b = ajouterReleve(b, { vehicule: 'JEEP', km: 112694 + i, niveau: 54, ts: new Date((T + i) * 1000), source: 'whatsapp' }); eq('255 car max', b.length <= 255, true); eq('dernier garde', JSON.parse(b).at(-1)[1], 112703); eq('helper corrompu repart a vide', JSON.parse(ajouterReleve('pas du json', { vehicule: 'VW', km: 5, niveau: null, ts: new Date(T * 1000), source: 'ha' })).length, 1); // --- ingererJeep : base simulee en memoire (04/10/2026) { const base = { releves: [], niveaux: [], branche: new Date('2026-10-04T11:28:00Z') }; const sql = async (q, v) => { if (q.includes('INSERT INTO releve_km')) { base.releves.push({ ts: v[0], km: v[1], niveau: v[2] }); return { rowCount: 1 }; } if (q.includes('INSERT INTO niveau_saisi')) { base.niveaux.push({ saisi_a: v[0], niveau: v[1] }); return { rowCount: 1 }; } return { rowCount: 1 }; }; const lire = async (q, v) => { if (q.includes('FROM releve_km') && q.includes("source = 'uconnect'")) { const r = base.releves.filter(x => x.ts >= v[0] && x.ts <= v[1] && x.niveau !== null).sort((a, b) => b.ts - a.ts); return { rows: r.length ? [{ ts: r[0].ts, niveau_pct: r[0].niveau }] : [] }; } if (q.includes('FROM releve_km')) { const r = [...base.releves].sort((a, b) => b.ts - a.ts); return { rows: r.length ? [{ km: r[0].km, ts: r[0].ts }] : [] }; } if (q.includes('FROM etiquette_branchement')) return { rows: base.etiquette ? [{ label: base.etiquette }] : [] }; if (q.includes('FROM evenement')) return { rows: [{ t: base.branche }] }; if (q.includes('FROM niveau_saisi')) return { rows: base.niveaux.filter(n => n.saisi_a >= v[0]) }; return { rows: [] }; }; const journ = async () => {}; const appels = []; const etiq = async (b, n, m) => { appels.push([b.toISOString(), n, m]); return 1; }; const etat = (km, niv, maj, branchee = 'on', borne = 'on', contact = 'off') => new Map([ [JEEP.contact, { state: contact }], [JEEP.odometre, { state: String(km) }], [JEEP.niveau, { state: String(niv) }], [JEEP.majA, { state: maj }], [JEEP.branchee, { state: branchee }], ['binary_sensor.victron_evcs_vehicle_connected', { state: borne }], ['input_number.victron_evcs_capacity_jeep_kwh', { state: '8.7' }], ['input_number.victron_evcs_autonomie_jeep_km', { state: '50' }]]); await ingererJeep(sql, lire, etat(112716, 46, '2026-10-04T11:28:39+00:00'), journ, etiq); eq('jeep : premier releve + niveau au branchement', [base.releves.length, base.niveaux.length, base.niveaux[0]?.niveau], [1, 1, 46]); await ingererJeep(sql, lire, etat(112716, 60, '2026-10-04T11:58:39+00:00'), journ, etiq); eq('jeep : compteur immobile et 2e maj du branchement -> rien', [base.releves.length, base.niveaux.length], [1, 1]); eq('jeep : branchement identifie UNE fois, avec le niveau lu', appels, [['2026-10-04T11:28:00.000Z', 46, 'branchee']]); base.branche = new Date('2026-10-05T16:00:00Z'); await ingererJeep(sql, lire, etat(112760, 30, '2026-10-05T17:00:00+00:00'), journ); eq('jeep : trajet -> releve ; maj 1 h apres le branchement -> pas de niveau', [base.releves.length, base.releves[1]?.km, base.niveaux.length], [2, 112760, 1]); await ingererJeep(sql, lire, etat(112700, 30, '2026-10-05T18:00:00+00:00'), journ); eq('jeep : compteur en baisse refuse', base.releves.length, 2); base.branche = new Date('2026-10-06T16:00:00Z'); await ingererJeep(sql, lire, etat(112800, 25, '2026-10-06T16:05:00+00:00', 'on', 'off'), journ, etiq); eq('jeep : borne debranchee -> pas d identification', appels.length, 1); eq('jeep : borne debranchee -> releve oui, niveau au branchement non', [base.releves.length, base.niveaux.length], [3, 1]); // Arrivee (05/10) : fin de trajet a 19:05, branchement a 19:06, Jeep non branchee pour elle. base.branche = new Date('2026-10-07T17:06:15Z'); await ingererJeep(sql, lire, etat(112850, 36, '2026-10-07T17:05:13+00:00', 'off', 'on'), journ, etiq); eq('jeep : arrivee 1 min avant le branchement -> identifiee, niveau d arrivee', appels.at(-1), ['2026-10-07T17:06:15.000Z', 36, 'arrivee']); await ingererJeep(sql, lire, etat(112850, 36, '2026-10-07T17:05:13+00:00', 'off', 'on'), journ, etiq); eq('jeep : arrivee -> une seule identification', appels.length, 2); base.branche = new Date('2026-10-08T17:30:00Z'); await ingererJeep(sql, lire, etat(112900, 30, '2026-10-08T17:05:00+00:00', 'off', 'on'), journ, etiq); eq('jeep : arrivee 25 min avant -> pas d identification', appels.length, 2); base.branche = new Date('2026-10-09T17:06:00Z'); base.etiquette = 'VW'; await ingererJeep(sql, lire, etat(112950, 40, '2026-10-09T17:05:00+00:00', 'off', 'on'), journ, etiq); eq('jeep : proprietaire a deja dit VW -> rien', [appels.length, base.niveaux.length], [2, 2]); // 07/10 : une autre voiture est branchee ; la Jeep publie APRES le branchement // avec le contact mis et se declare « branchee » (cas extreme) -> pas JEEP. base.branche = new Date('2026-10-10T10:32:00Z'); base.etiquette = null; await ingererJeep(sql, lire, etat(113000, 50, '2026-10-10T10:37:20+00:00', 'on', 'on', 'on'), journ, etiq); eq('jeep : contact mis apres le branchement -> pas d identification', appels.length, 2); } if (process.argv.includes('--vues')) { const pg = (await import('pg')).default; const c = new pg.Client(); await c.connect(); try { await c.query('BEGIN'); await creerSchemaKm(c); // Vehicules fictifs, annee 2020 : rien ne se melange au reel, et tout est annule. const ins = (v, ts, km, n, cap, auto, elec) => c.query( 'INSERT INTO releve_km (vehicule, ts, km, niveau_pct, source, capacite_kwh, autonomie_ref_km, tout_electrique) VALUES ($1,$2,$3,$4,\'test\',$5,$6,$7)', [v, ts, km, n, cap, auto, elec]); await ins('TESTH', '2020-01-01T18:00Z', 1000, 54, 8.7, 50, false); await ins('TESTH', '2020-01-02T18:00Z', 1030, 20, 8.7, 50, false); // pas de charge : 34 % x 8,7 = 2,958 kWh await ins('TESTH', '2020-01-04T18:00Z', 1080, 0, 8.7, 50, false); // batterie vide : essence possible await ins('TESTE', '2020-01-01T18:00Z', 500, 80, 15, 200, true); await ins('TESTE', '2020-01-03T18:00Z', 600, 0, 15, 200, true); // electrique : 0 % reste valide await c.query(`INSERT INTO session_charge (debut, fin, terminee, vehicule, vehicule_sur, energie_kwh, energie_station_kwh) VALUES ('2020-01-03T01:00Z','2020-01-03T03:00Z',true,'TESTH',true,4.0,4.2)`); const { rows } = await c.query("SELECT * FROM km_intervalle WHERE vehicule LIKE 'TEST%' ORDER BY vehicule, debut"); const h1 = rows.find(r => r.vehicule === 'TESTH' && r.km_parcourus === 30); proche('H1 kWh consommes = 0 + 0,34 x 8,7', h1.kwh_consommes, 2.958); proche('H1 km/kWh', h1.km_par_kwh, 30 / 2.958); proche('ref km/kWh = 50/8,7', h1.km_par_kwh_ref, 50 / 8.7); eq('H1 valide', h1.statut, 'valide'); const h2 = rows.find(r => r.vehicule === 'TESTH' && r.km_parcourus === 50); proche('H2 charge de la borne comptee (4,2)', h2.kwh_charges, 4.2); eq('H2 hybride vide au retour', h2.statut, 'batterie vide au retour : km essence possibles'); const e1 = rows.find(r => r.vehicule === 'TESTE'); proche('E1 kWh = 0,80 x 15', e1.kwh_consommes, 12); eq('E1 electrique a 0 % valide', e1.statut, 'valide'); // Capacite : branchement 2020-02-01 10:00 -> 14:00, niveau 50 % saisi a // 10:40 (compteur deja a 101,0 : la voiture chargeait), compteur 100,0 au // branchement et 104,5 au debranchement, plein vu a 13:00. const ev = (ts, apres) => c.query(`INSERT INTO evenement (ts, genre, source, resume, detail) VALUES ($1,'vehicule','test','test',$2)`, [ts, JSON.stringify({ entite: 'binary_sensor.victron_evcs_vehicle_connected', apres })]); const cpt = (ts, v) => c.query(`INSERT INTO mesure (ts, cle, valeur) VALUES ($1,'sensor.evcs_hq2239dnt7p_total_energy',$2)`, [ts, v]); await ev('2020-02-01T10:00:00.123456Z', 'on'); await ev('2020-02-01T14:00Z', 'off'); await c.query(`INSERT INTO etiquette_branchement (branchement, label, source_airtria) VALUES ('2020-02-01T10:00:00.123Z','TESTH','test')`); await c.query(`INSERT INTO niveau_saisi (saisi_a, niveau_pct, ref_kwh) VALUES ('2020-02-01T10:40Z', 50, 101.0)`); await cpt('2020-02-01T09:59Z', 100.0); await cpt('2020-02-01T13:59Z', 104.5); await c.query(`INSERT INTO decision (ts, etat) VALUES ('2020-02-01T13:00Z','day_vehicle_full_idle')`); const capa = async () => (await c.query("SELECT * FROM capacite_estimee WHERE branche >= '2020-02-01' AND branche < '2020-02-02'")).rows[0]; let cap = await capa(); proche('energie depuis le branchement, pas la saisie', cap.kwh_livres, 4.5); proche('capacite = 4,5 / 0,5', cap.capacite_kwh, 9.0); eq('vehicule de l etiquette (horodatage a la microseconde pres)', cap.vehicule, 'TESTH'); await c.query(`INSERT INTO session_charge (debut, fin, terminee, vehicule, vehicule_sur, energie_kwh, energie_station_kwh) VALUES ('2020-02-01T10:01Z','2020-02-01T13:00Z',true,'TESTX',true,4.4,4.5)`); eq('la session confirmee prime sur l etiquette', (await capa()).vehicule, 'TESTX'); await c.query(`DELETE FROM decision WHERE ts = '2020-02-01T13:00Z'`); cap = await capa(); eq('pas plein, pas de releve -> pas de capacite', cap.capacite_kwh, null); await ins('TESTX', '2020-02-01T14:20Z', 777, 80, 15, 200, true); proche('releve dans l heure : 4,5 / (80-50) %', (await capa()).capacite_kwh, 15.0); // Niveau de fin donne par le proprietaire (10/10) : il prime sur le releve et sur le plein. await c.query(`INSERT INTO niveau_fin (saisi_a, niveau_pct, source) VALUES ('2020-02-01T14:00Z', 95, 'test')`); proche('niveau de fin saisi : 4,5 / (95-50) %', (await capa()).capacite_kwh, 10.0); // Saisie ancienne sans compteur historise : somme des sessions du branchement. await ev('2020-03-01T20:00Z', 'on'); await ev('2020-03-02T04:00Z', 'off'); await c.query(`INSERT INTO session_charge (debut, fin, terminee, vehicule, vehicule_sur, energie_kwh, energie_station_kwh) VALUES ('2020-03-01T23:25Z','2020-03-02T01:41Z',true,'TESTV',true,7.6,7.7)`); await c.query(`INSERT INTO niveau_saisi (saisi_a, niveau_pct, ref_kwh, source) VALUES ('2020-03-01T20:00:01Z', 5, NULL, 'test')`); await c.query(`INSERT INTO decision (ts, etat) VALUES ('2020-03-02T01:42Z','night_recovery_done_idle')`); const v = (await c.query("SELECT * FROM capacite_estimee WHERE branche >= '2020-03-01' AND branche < '2020-03-02'")).rows[0]; proche('sans compteur : sessions du branchement 7,7 / 0,95', v.capacite_kwh, 7.7 / 0.95); } finally { await c.query('ROLLBACK'); await c.end(); } } console.log(`${ok}/${ok + ko}`); process.exit(ko ? 1 : 0);