Extraction du cœur autonomie de calculs.trans-former.fr (12 modules, sans singleton, couplages sans mutation, console derrière debug), découpage en trois moteurs d'axe + intégrateur (mode global = calculs, mode par_axe = plateforme), moteurs commun.contexte (Corse 2A/2B, DOM 97x corrigés) et commun.geometrie, table des coefficients harmonisés (docs/CONSTANTES.md pour Jules, R-7), validation structurelle des données (schemas/), tests hors ligne : 28 assertions d'origine + épingle générée depuis le code d'astro-pro + écarts d'harmonisation figés + contrat des moteurs. dist/ committé. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019UBHYPdeQYy2m1GVJp6b12
280 lines
14 KiB
JavaScript
280 lines
14 KiB
JavaScript
// 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');
|
||
});
|