// Tests d'origine du cœur autonomie (calculs/tests/test-algo.js, 28 assertions, // fixtures figées du 16/08/2026) portés en node:test. Ces tests ne se désactivent // jamais (AGENTS.md règle 6). Coefficients d'ORIGINE injectés // (fixtures/coefficients-2026-05.json) : ils vérifient l'extraction, pas l'harmonisation. import { test } from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { performance } from 'node:perf_hooks'; import { selectionner, dimension_optimale, cout_typologie, configPourAxes, auMoinsUnAxe, } from '../dist/autonomie/noyau/index.js'; const options = JSON.parse(readFileSync(new URL('../fixtures/options.json', import.meta.url), 'utf8')); const postes = JSON.parse(readFileSync(new URL('../fixtures/postes.json', import.meta.url), 'utf8')); const climat = JSON.parse(readFileSync(new URL('../fixtures/france_climat.json', import.meta.url), 'utf8')); const config = JSON.parse(readFileSync(new URL('../fixtures/algo-config.json', import.meta.url), 'utf8')); const coefficients = JSON.parse(readFileSync(new URL('../fixtures/coefficients-2026-05.json', import.meta.url), 'utf8')); const profils = JSON.parse(readFileSync(new URL('../fixtures/profils-reference.json', import.meta.url), 'utf8')); const reglages = { config, coefficients }; // ========== SECTION 1 : Typologies echantillon M1a-RECAP ========== test('Section 1 : typologies echantillon M1a-RECAP', () => { const inputs_ref = { nb_personnes: 4, surface_toit_m2: 80, orientation_toit: 'sud', surface_parcelle_m2: 800, metres_haie_possibles: 0, temps_dispo: 2 }; const ctx_ref = { dju: 2200, zone_biogeo: 'OC', pluviometrie_mm: 800, zone_climatique_rt: 'H2a', zone_rt_class: 'H2' }; // 1a — Cuve beton enterree : 4 pers → base 3 + 4×1.5 = 9 m³ { const typo = options.find(o => o.code === 'stockage_beton_5m3'); if (typo) { const dim = dimension_optimale(typo, inputs_ref, ctx_ref, coefficients); assert.ok(dim && dim.valeur === 9, 'Cuve beton 4 pers = 9 m3 (base 3 + 4x1.5)'); assert.ok(dim && dim.unite === 'm³', 'Cuve beton unite = m3'); } else { console.log(' SKIP: stockage_beton_5m3 non trouve'); } } // 1b — PV kit string toit 80m² Sud : 80×0.15×1.0 = 12 → clamp max 9 kWc { const typo = options.find(o => o.code === 'pv_kit_3kwc_string'); if (typo) { const dim = dimension_optimale(typo, inputs_ref, ctx_ref, coefficients); assert.ok(dim && dim.valeur === 9, 'PV toit 80m2 Sud = 9 kWc (clamp max)'); assert.ok(dim && dim.unite === 'kWc', 'PV unite = kWc'); } else { console.log(' SKIP: pv_kit_3kwc_string non trouve'); } } // 1c — Serre tunnel 4 pers : base 3 + 4×3 = 15 → step 6 → 18 m² { const typo = options.find(o => o.code === 'basse_serre_6m2'); if (typo) { const dim = dimension_optimale(typo, inputs_ref, ctx_ref, coefficients); if (dim) { assert.ok(dim.valeur === 18, 'Serre 4 pers = 18 m2 (step 6: 15 -> 18)'); } else { console.log(' SKIP: basse_serre_6m2 param_dim manquant (strategie fixe)'); } } else { console.log(' SKIP: basse_serre_6m2 non trouve'); } } // 1d — Phyto Aquatiris 4 pers : base 1 + 4×1 = 5 EH { const typo = options.find(o => o.code === 'eg_aquatiris' || o.code === 'eg_filiere_agreee_aquatiris'); if (typo) { const dim = dimension_optimale(typo, inputs_ref, ctx_ref, coefficients); if (dim && dim.strategie === 'par_personne') { assert.ok(dim.valeur === 5, 'Phyto 4 pers = 5 EH'); } else { console.log(' SKIP: aquatiris strategie fixe ou param manquant'); } } else { console.log(' SKIP: eg_aquatiris non trouve'); } } // 1e — th_ballon_500 : unite = L (particularite ballon_tampon) { const typo = options.find(o => o.code === 'th_ballon_500'); if (typo) { const dim = dimension_optimale(typo, inputs_ref, ctx_ref, coefficients); assert.ok(dim && dim.unite === 'L', 'ballon_tampon unite = L (pas forfait)'); const cout = cout_typologie(typo, dim); assert.ok(typeof cout === 'number' && cout > 0, 'ballon_tampon cout > 0 (prix_par_unite 1.2 EUR/L x volume_L)'); if (dim) console.log(' th_ballon_500:', dim.valeur, dim.unite, '=', cout, 'EUR'); } else { console.log(' SKIP: th_ballon_500 non trouve'); } } }); // ========== SECTION 2 : 3 profils de reference ========== test('Section 2 : profils de reference (critere 1 : ecart +-5% budget)', () => { const resultats_profils = []; for (const profil of profils) { const t0 = performance.now(); const result = selectionner(profil.inputs, options, postes, climat, reglages, false); const dt = performance.now() - t0; console.log('--- ' + profil.nom + ' (' + dt.toFixed(1) + 'ms) ---'); console.log('Total: ' + result.total_eur_ht + ' EUR (cible ' + result.budget_cible + ' EUR, ecart ' + result.ecart_budget_pct.toFixed(1) + '%)'); console.log('Status:', result.status); if (process.env.VERBOSE) { for (const axe of ['eau', 'energie', 'alim']) { if (result.axes[axe]) { console.log(' ' + axe + ': ' + result.axes[axe].sous_total_eur_ht + ' EUR (' + result.axes[axe].postes.length + ' postes)'); for (const p of result.axes[axe].postes) { const dim = p.dimension ? (p.dimension.valeur + p.dimension.unite) : ''; console.log(' - ' + p.nom_affichage + ' [' + p.typologie_choisie + '] ' + dim + ' : ' + p.cout_eur_ht + ' EUR'); } } } } assert.ok(Math.abs(result.ecart_budget_pct) <= 5, profil.nom + ' : ecart ' + result.ecart_budget_pct.toFixed(1) + '% (<= 5%)'); assert.ok(dt < 50, profil.nom + ' : ' + dt.toFixed(1) + 'ms (< 50ms)'); assert.ok(result.status !== undefined, profil.nom + ' : status defini'); resultats_profils.push({ profil, result, dt }); } }); // ========== SECTION 3 : Determinisme (100 iterations) ========== test('Section 3 : determinisme (100 iterations)', () => { const profil_test = profils[1]; const r1 = selectionner(profil_test.inputs, options, postes, climat, reglages, false); let deterministe = true; for (let i = 0; i < 99; i++) { const r = selectionner(profil_test.inputs, options, postes, climat, reglages, false); if (r.total_eur_ht !== r1.total_eur_ht || r.status !== r1.status) { deterministe = false; console.error(' Iteration ' + (i + 2) + ' : total=' + r.total_eur_ht + ' != ' + r1.total_eur_ht); break; } } assert.ok(deterministe, 'Determinisme : 100 iterations profil B -> meme resultat'); }); // ========== SECTION 4 : BUDGET_INSUFFISANT ========== test('Section 4 : BUDGET_INSUFFISANT', () => { const inputs_mini = { budget_eur: 100, code_postal: '75019', nb_personnes: 4, surface_parcelle_m2: 500, surface_toit_m2: 50, orientation_toit: 'sud', pente_toit_deg: 30, metres_haie_possibles: 0, temps_dispo: 1 }; let no_crash = true; let result_mini; try { result_mini = selectionner(inputs_mini, options, postes, climat, reglages, false); } catch (e) { no_crash = false; console.error(' CRASH:', e.message); } assert.ok(no_crash, 'Budget 100 EUR : pas de crash'); if (result_mini) { assert.ok(['OK', 'BUDGET_INSUFFISANT', 'AJOUTER_OPTIONNEL'].includes(result_mini.status), 'Budget 100 EUR : status valide'); console.log(' Status:', result_mini.status, '|', result_mini.message_status || '(pas de message)'); } }); // ========== SECTION 5 : Mode debug ========== test('Section 5 : mode debug', () => { const result_debug = selectionner(profils[0].inputs, options, postes, climat, reglages, true); assert.ok(result_debug.debug !== undefined, 'Mode debug : objet debug present'); assert.ok(Array.isArray(result_debug.debug.steps), 'Mode debug : steps est un tableau'); assert.ok(result_debug.debug.steps.length >= 5, 'Mode debug : >= 5 etapes tracees'); console.log(' Etapes tracees:', result_debug.debug.steps.map(s => s.label || s.etape).join(', ')); }); // ========== SECTION 6 : Couplages (20 simulations) ========== test('Section 6 : couplages contraints (20 simulations)', () => { // Utiliser les codes BDD réels (après résolution des alias de algo-config.json) // stherm_ballon_tampon → th_ballon_500 (alias BDD) const COUPLAGES_REQUIS_ATTENDUS = { 'bois_poele_hydro': ['bois_conduit_fumee', 'th_ballon_500'], 'filtration_osmose_inverse': ['filtration_particulaire_simple'], 'filtration_uv_standalone': ['filtration_particulaire_simple'], }; const configs_test = [ { budget_eur: 5000, cp: '75000', pers: 1, parcelle: 100, toit: 20, orient: 'sud', temps: 1 }, { budget_eur: 10000, cp: '31000', pers: 2, parcelle: 300, toit: 40, orient: 'sud', temps: 1 }, { budget_eur: 20000, cp: '69000', pers: 3, parcelle: 500, toit: 60, orient: 'sud-est', temps: 2 }, { budget_eur: 35000, cp: '13100', pers: 4, parcelle: 800, toit: 80, orient: 'sud', temps: 2 }, { budget_eur: 50000, cp: '06000', pers: 5, parcelle: 1200, toit: 100, orient: 'sud', temps: 3 }, { budget_eur: 80000, cp: '01000', pers: 6, parcelle: 2500, toit: 120, orient: 'sud', temps: 3 }, { budget_eur: 15000, cp: '59000', pers: 2, parcelle: 400, toit: 50, orient: 'est', temps: 1 }, { budget_eur: 25000, cp: '33000', pers: 3, parcelle: 700, toit: 70, orient: 'ouest', temps: 2 }, { budget_eur: 45000, cp: '67000', pers: 4, parcelle: 1000, toit: 90, orient: 'sud-ouest',temps: 3 }, { budget_eur: 60000, cp: '38000', pers: 5, parcelle: 1500, toit: 110, orient: 'sud', temps: 2 }, { budget_eur: 12000, cp: '44000', pers: 2, parcelle: 250, toit: 35, orient: 'sud', temps: 1 }, { budget_eur: 30000, cp: '34000', pers: 3, parcelle: 600, toit: 65, orient: 'sud-est', temps: 2 }, { budget_eur: 55000, cp: '76000', pers: 5, parcelle: 900, toit: 95, orient: 'sud', temps: 3 }, { budget_eur: 18000, cp: '29000', pers: 2, parcelle: 350, toit: 45, orient: 'nord', temps: 1 }, { budget_eur: 40000, cp: '83000', pers: 4, parcelle: 1100, toit: 85, orient: 'sud', temps: 2 }, { budget_eur: 70000, cp: '64000', pers: 6, parcelle: 3000, toit: 130, orient: 'sud', temps: 3 }, { budget_eur: 22000, cp: '57000', pers: 3, parcelle: 450, toit: 55, orient: 'est', temps: 2 }, { budget_eur: 48000, cp: '30000', pers: 5, parcelle: 1300, toit: 105, orient: 'sud-ouest',temps: 3 }, { budget_eur: 8000, cp: '75019', pers: 1, parcelle: 100, toit: 25, orient: 'sud', temps: 1 }, { budget_eur: 65000, cp: '11000', pers: 6, parcelle: 2000, toit: 115, orient: 'sud', temps: 3 }, ]; let violations = 0; for (let i = 0; i < configs_test.length; i++) { const c = configs_test[i]; const inputs_fuzz = { budget_eur: c.budget_eur, code_postal: c.cp, nb_personnes: c.pers, surface_parcelle_m2: c.parcelle, surface_toit_m2: c.toit, orientation_toit: c.orient, pente_toit_deg: 30, metres_haie_possibles: c.parcelle > 500 ? 30 : 0, temps_dispo: c.temps, }; let result_fuzz; try { result_fuzz = selectionner(inputs_fuzz, options, postes, climat, reglages, false); } catch (e) { violations++; console.error(' Simu ' + (i + 1) + ' CRASH:', e.message); continue; } const tous_codes = ['eau', 'energie', 'alim'] .flatMap(axe => result_fuzz.axes[axe] ? result_fuzz.axes[axe].postes : []) .map(p => p.typologie_choisie); for (const [typo_code, requis_list] of Object.entries(COUPLAGES_REQUIS_ATTENDUS)) { if (tous_codes.includes(typo_code)) { for (const requis of requis_list) { if (!tous_codes.includes(requis)) { violations++; console.error(' Simu ' + (i + 1) + ' : COUPLAGE VIOLE — ' + typo_code + ' sans ' + requis); } } } } } assert.ok(violations === 0, 'Couplages : 0 violation sur 20 simulations (trouve: ' + violations + ')'); }); // ========== SECTION 7 : Toggles d'axes (V3 M2) ========== test('Section 7 : toggles d axes (V3 M2)', () => { const combinaisons = [ { nom: 'eau seul', actifs: { eau: true, energie: false, alim: false } }, { nom: 'energie seul', actifs: { eau: false, energie: true, alim: false } }, { nom: 'alim seul', actifs: { eau: false, energie: false, alim: true } }, { nom: 'eau+energie', actifs: { eau: true, energie: true, alim: false } }, { nom: 'eau+alim', actifs: { eau: true, energie: false, alim: true } }, { nom: 'energie+alim', actifs: { eau: false, energie: true, alim: true } }, ]; let ok7 = true; for (const profil of profils) { for (const combo of combinaisons) { const cfg = configPourAxes(config, combo.actifs); const r = selectionner(profil.inputs, options, postes, climat, { config: cfg, coefficients }, false); // 1. Axe désactivé -> 0 poste, 0 EUR for (const axe of ['eau', 'energie', 'alim']) { if (combo.actifs[axe] === false) { const a = r.axes[axe]; if (a && (a.postes.length > 0 || a.sous_total_eur_ht > 0)) { ok7 = false; console.error(' FAIL: ' + profil.nom + ' / ' + combo.nom + ' — axe ' + axe + ' desactive mais ' + a.postes.length + ' postes, ' + a.sous_total_eur_ht + ' EUR'); } } } // 2. Le total reste calibre sur le budget saisi (jamais au-dela de +25%) if (r.total_eur_ht > profil.inputs.budget_eur * 1.25) { ok7 = false; console.error(' FAIL: ' + profil.nom + ' / ' + combo.nom + ' — total ' + r.total_eur_ht + ' >> budget ' + profil.inputs.budget_eur); } // 3. Déterminisme conservé const r2 = selectionner(profil.inputs, options, postes, climat, { config: configPourAxes(config, combo.actifs), coefficients }, false); if (r2.total_eur_ht !== r.total_eur_ht) { ok7 = false; console.error(' FAIL: ' + profil.nom + ' / ' + combo.nom + ' — non deterministe'); } } } assert.ok(ok7, 'Toggles axes : 3 profils x 6 combinaisons (axe eteint = 0 poste, total calibre, deterministe)'); // Cas limite : 0 axe actif -> l'UI ne lance pas l'algo (garde auMoinsUnAxe) assert.equal(auMoinsUnAxe({ eau: false, energie: false, alim: false }), false, '0 axe actif detecte (etat vide explicite, pas de crash)'); assert.equal(auMoinsUnAxe({ eau: true, energie: false, alim: false }), true, 'auMoinsUnAxe : 1 axe actif OK'); // Config d'origine non mutée assert.ok(!config._axes_desactives, 'configPourAxes ne mute pas la config d origine'); });