Files
moteurs/test/noyau-origine.test.mjs
JulesandClaude Fable 5.1 69809df5d1 LOT P1.0 : paquet @transformer/moteurs v0.1.0
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
2026-09-28 17:06:49 +02:00

280 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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');
});