// Correctifs v0.1.1 — SPEC menu branches fiches §12, n° 1, 2, 3, 5 et 7. // Chaque test ÉCHOUE sur v0.1.0 (vérifié sur master 648015a) et passe après le // correctif. Jeux : la maison d'essai du 02/10 (3 personnes, toit 90 m² sud, // parcelle 600 m², CP 31000, 30 ml de haie, temps 2), la maison B de P1.2 à // 6 000 € (toit 89 m², parcelle 457 m², sans personnes ni orientation, temps 1) // et le profil de référence A. Coefficients harmonisés du paquet. import { test } from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { eau, energie, alimentation, contexte as moteurContexte, autonomie, COEFFICIENTS } from '../dist/index.js' const lire = (f) => JSON.parse(readFileSync(new URL(`../fixtures/${f}`, import.meta.url), 'utf8')) const options = lire('options.json') const postes = lire('postes.json') const france_climat = lire('france_climat.json') const algo_config = lire('algo-config.json') const profils = lire('profils-reference.json') const donnees = { postes, options, algo_config, france_climat } const MOTEURS = { eau, energie, alim: alimentation } const alias = algo_config.couplages_alias const ESSAI = { code_postal: '31000', nb_personnes: 3, surface_toit_m2: 90, orientation_toit: 'sud', surface_parcelle_m2: 600, pente_toit_deg: 30, metres_haie_possibles: 30, temps_dispo: 2 } const MAISON_B = { code_postal: '31000', surface_toit_m2: 89, pente_toit_deg: 30, temps_dispo: 1, surface_parcelle_m2: 457 } const ctx31 = moteurContexte.calculer({ code_postal: '31000' }, { france_climat }).sorties const calcule = (axe, autodiag, budget) => MOTEURS[axe].calculer({ autodiag, budget_axe: budget, contexte: ctx31 }, donnees).sorties const requisDe = (code) => { const o = options.find((x) => x.code === code) return o ? autonomie.noyau.parseJsonArr(o.couplages_requis).map((r) => alias[r] || r) : [] } /** Lignes forcées que plus aucune ligne non forcée ne requiert. */ const orphelins = (s) => s.postes.filter((p) => p.force_par_couplage && !s.postes.some((y) => !y.force_par_couplage && requisDe(y.typologie_choisie).includes(p.typologie_choisie))).map((p) => p.typologie_choisie) const doublons = (s) => s.typologies.filter((c, i) => s.typologies.indexOf(c) !== i) const postesMultiples = (s) => { const n = {} for (const p of s.postes) if (!p.force_par_couplage) n[p.poste_id] = (n[p.poste_id] || 0) + 1 return Object.entries(n).filter(([, k]) => k > 1).map(([k]) => k) } test('n° 1 — une typologie n\'est jamais retenue deux fois dans un axe, un poste n\'a qu\'une ligne (essai : eau 15 000 et 25 000, énergie 6 000, jardin 15 000)', () => { for (const [axe, budget] of [['eau', 15000], ['eau', 25000], ['energie', 6000], ['alim', 15000], ['alim', 40000]]) { const s = calcule(axe, ESSAI, budget) assert.deepEqual(doublons(s), [], `${axe} ${budget} : doublons`) assert.deepEqual(postesMultiples(s), [], `${axe} ${budget} : plusieurs lignes pour un poste`) } }) test('n° 2 — le journal des couplages n\'écrit chaque FORCE qu\'une fois, et un poste déjà retenu n\'est pas re-forcé (B de P1.2, énergie 6 000 ; A, eau 7 500)', () => { for (const [axe, autodiag, budget] of [['energie', MAISON_B, 6000], ['eau', profils[0].inputs, 7500], ['energie', ESSAI, 10000]]) { const s = calcule(axe, autodiag, budget) const force = s.couplages.filter((l) => l.startsWith('FORCE')) assert.equal(new Set(force).size, force.length, `${axe} ${budget} : FORCE répété — ${force.join(' | ')}`) const forces = s.postes.filter((p) => p.force_par_couplage).map((p) => p.typologie_choisie) for (const code of forces) assert.equal(s.typologies.filter((c) => c === code).length, 1, `${axe} ${budget} : ${code} forcé alors qu'il est déjà là`) assert.equal(s.sous_total_eur_ht, s.postes.reduce((a, p) => a + p.cout_eur_ht, 0), 'sous-total = somme des lignes') } // RECAP-LOT-P1.2 §5 ① : sur B à 6 000 € le conduit sortait deux fois (5 320 € au lieu de 3 940 €) const b = calcule('energie', MAISON_B, 6000) assert.equal(b.typologies.filter((c) => c === 'bois_conduit_fumee').length, 1, 'un seul conduit') }) test('n° 3 — EAU : correcte à 3 000 €, elle ne devient pas « budget insuffisant » à 6 000 € (essai) ni à 5 500-7 500 € (A, B)', () => { const e3 = calcule('eau', ESSAI, 3000) const e6 = calcule('eau', ESSAI, 6000) assert.equal(e3.status, 'OK') assert.notEqual(e6.status, 'BUDGET_INSUFFISANT', e6.message_status) for (const p of profils.slice(0, 2)) { for (const b of [5500, 6000, 6500, 7000, 7500]) { const ctx = moteurContexte.calculer({ code_postal: p.inputs.code_postal }, { france_climat }).sorties const s = eau.calculer({ autodiag: p.inputs, budget_axe: b, contexte: ctx }, donnees).sorties assert.notEqual(s.status, 'BUDGET_INSUFFISANT', `${p.nom} eau ${b} : ${s.message_status}`) } } }) test('n° 5 — aucun accessoire forcé sans son principal (conduit sans poêle, ballon sans poêle hydro, filière sans noue) : essai et A, tous budgets', () => { for (const [nom, autodiag] of [['essai', ESSAI], ['A', profils[0].inputs], ['B-P1.2', MAISON_B]]) { for (const axe of ['eau', 'energie']) { for (const b of [3000, 4000, 6000, 7500, 10000, 15000, 18000, 25000]) { const s = calcule(axe, autodiag, b) assert.deepEqual(orphelins(s), [], `${nom} ${axe} ${b} : accessoire forcé orphelin`) } } } }) test('n° 5 — noyau : appliqueCouplages lâche une ligne forcée dont le principal a disparu ; un downgrade du principal lâche ses accessoires', () => { const reglages = { config: autonomie.noyau.configPourAxes(algo_config, { eau: false, energie: true, alim: false }), coefficients: COEFFICIENTS } const vide = () => ({ postes: [], sous_total_eur_ht: 0, budget_alloue: 0, poids: 0 }) const sel = { eau: vide(), alim: vide(), energie: autonomie.noyau.selectionnePoste(25000, ESSAI, ctx31, options, postes, 'energie', reglages) } // Le poste chauffage prend le poêle hydro (il requiert conduit + ballon tampon) const typo = options.find((o) => o.code === 'bois_poele_hydro') const cout = Math.round(autonomie.noyau.cout_typologie(typo, null, ESSAI)) const i = sel.energie.postes.findIndex((p) => p.poste_id === 'energie_chauffage_bois') sel.energie.postes[i] = { ...sel.energie.postes[i], typologie_choisie: typo.code, nom_affichage: typo.nom, cout_eur_ht: cout, couplages_requis: autonomie.noyau.parseJsonArr(typo.couplages_requis), couplages_exclus: [] } sel.energie.sous_total_eur_ht = sel.energie.postes.reduce((a, p) => a + p.cout_eur_ht, 0) const c1 = autonomie.noyau.appliqueCouplages(sel, options, postes, reglages) const forces = c1.axes.energie.postes.filter((p) => p.force_par_couplage).map((p) => p.typologie_choisie) assert.ok(forces.includes('bois_conduit_fumee'), 'conduit forcé') // Le principal disparaît : la passe suivante retire ses accessoires, et le sous-total suit const sans = { ...c1.axes, energie: { ...c1.axes.energie, postes: c1.axes.energie.postes.filter((p) => p.typologie_choisie !== 'bois_poele_hydro') } } sans.energie.sous_total_eur_ht = sans.energie.postes.reduce((a, p) => a + p.cout_eur_ht, 0) const c2 = autonomie.noyau.appliqueCouplages(sans, options, postes, reglages) assert.ok(c2.couplages_appliques.some((l) => l.startsWith('RETIRE "bois_conduit_fumee"')), c2.couplages_appliques.join(' | ')) assert.ok(!c2.axes.energie.postes.some((p) => p.force_par_couplage && p.typologie_choisie === 'bois_conduit_fumee')) assert.equal(c2.axes.energie.sous_total_eur_ht, c2.axes.energie.postes.reduce((a, p) => a + p.cout_eur_ht, 0)) // CAS A sur un budget trop petit : le poêle hydro redescend, le conduit forcé ne reste pas verrouillé const r = autonomie.noyau.reajusteBudget(c1, 8000, options, postes, ESSAI, ctx31, reglages, false) const fin = autonomie.noyau.appliqueCouplages(r.axes, options, postes, reglages).axes.energie assert.deepEqual(orphelins({ postes: fin.postes }), [], 'après réajustement, aucun accessoire orphelin') }) test('n° 2 (journal) — finaliseSelection n\'additionne pas son propre journal à chaque tour', () => { const reglages = { config: autonomie.noyau.configPourAxes(algo_config, { eau: true, energie: false, alim: false }), coefficients: COEFFICIENTS } const vide = () => ({ postes: [], sous_total_eur_ht: 0, budget_alloue: 0, poids: 0 }) const sel = { energie: vide(), alim: vide(), eau: autonomie.noyau.selectionnePoste(7500, profils[0].inputs, ctx31, options, postes, 'eau', reglages) } const fin = autonomie.noyau.finaliseSelection(sel, 7500, options, postes, profils[0].inputs, ctx31, reglages) const force = fin.couplages.filter((l) => l.startsWith('FORCE')) assert.equal(new Set(force).size, force.length, fin.couplages.join(' | ')) }) test('trouvé en chemin (n° 6) — une ligne remplacée par le réajustement porte SA dimension, pas celle de la ligne remplacée', () => { for (const [axe, autodiag, budgets] of [['energie', ESSAI, [6000, 10000, 40000]], ['eau', ESSAI, [6000, 15000]], ['alim', profils[2].inputs, [21600]]]) { for (const b of budgets) { const s = calcule(axe, autodiag, b) for (const p of s.postes) { const o = options.find((x) => x.code === p.typologie_choisie) const attendu = autonomie.noyau.dimension_optimale(o, autodiag, ctx31, COEFFICIENTS) || { valeur: o.quantite_default || 1, unite: o.unite || '' } assert.deepEqual({ valeur: p.dimension.valeur, unite: p.dimension.unite }, { valeur: attendu.valeur, unite: attendu.unite }, `${axe} ${b} : ${p.typologie_choisie}`) } if (axe === 'energie' && !s.postes.some((p) => p.dimension.unite === 'kWc')) assert.equal(s.taux_couverture, null, `${b} : sans kWc, pas de taux`) } } }) test('n° 7 — jardin : plus de lignes répétées, la quantité d\'un poste est sa dimension (essai 15 000, B 7 000)', () => { const s = calcule('alim', ESSAI, 15000) assert.deepEqual(doublons(s), []) const planche = s.postes.find((p) => p.typologie_choisie === 'basse_planche_bec') assert.ok(planche && planche.dimension.unite === 'm²' && planche.dimension.valeur > 0, 'la planche porte sa surface') assert.equal(s.postes.length, 4, 'quatre postes, quatre lignes') const ctxB = moteurContexte.calculer({ code_postal: profils[1].inputs.code_postal }, { france_climat }).sorties const b = alimentation.calculer({ autodiag: profils[1].inputs, budget_axe: 7000, contexte: ctxB }, donnees).sorties assert.deepEqual(doublons(b), []) // Le budget que les répétitions consommaient reste disponible, et le statut le dit assert.equal(b.status, 'AJOUTER_OPTIONNEL') })