// Non-régression du découpage par axe (LOT P1.0 point 4, amendé le 28/09 et le 05/10). // // 1. Avec le jeu de coefficients D'ORIGINE (fixtures/coefficients-2026-05.json), // le pipeline complet extrait rend, sur les trois profils de référence, la // sélection de selectionner() d'origine — épinglée dans // fixtures/sorties-origine-2026-09-28.json, générée DEPUIS le code d'astro-pro // (scripts/pin-origine.mjs), pas depuis ce paquet — AUX ÉCARTS CORRECTIFS PRÈS : // l'épingle d'origine porte les défauts corrigés en v0.1.1 (doublons, // accessoires forcés orphelins, statut non monotone, journal répété ; SPEC menu // §12) et n'est pas regénérée. Chaque cellule qui s'en écarte est figée dans // fixtures/ecarts-correctifs-.json (scripts/ecarts-correctifs.mjs) ; une // cellule absente de ce fichier doit rester identique à l'origine. // 2. Chaque moteur d'axe, à budget d'axe égal, rend la sélection du mode « un // seul axe actif » de selectionner() de ce paquet (propriété du découpage). // 3. Avec la table HARMONISÉE, les écarts de sélection et de taux par rapport à // l'origine sont ceux figés dans fixtures/ecarts-harmonisation-.json // (relus par Jules avec docs/CONSTANTES.md). Les modifier = refiger sciemment. import { test } from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { eau, energie, alimentation, integrateur, autonomie, COEFFICIENTS } from '../dist/index.js' import { calculeEcarts } from '../scripts/ecarts-harmonisation.mjs' import { calculeEcartsCorrectifs, PIN_ORIGINE } from '../scripts/ecarts-correctifs.mjs' 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 origine = lire('coefficients-2026-05.json') const pin = lire('sorties-origine-2026-09-28.json') const ecartsPin = lire('ecarts-harmonisation-2026-10-05.json') const correctifsPin = lire('ecarts-correctifs-2026-10-05.json') const AXES = ['eau', 'energie', 'alim'] const MOTEURS = { eau, energie, alim: alimentation } const resumePostes = (liste) => liste.map((p) => ({ poste_id: p.poste_id, typologie_choisie: p.typologie_choisie, dimension: p.dimension ? { valeur: p.dimension.valeur, unite: p.dimension.unite } : null, cout_eur_ht: p.cout_eur_ht, force_par_couplage: !!p.force_par_couplage })) test('pipeline complet extrait = selectionner() d\'origine aux écarts correctifs figés près (coefficients d\'origine, trois profils)', () => { assert.equal(PIN_ORIGINE, 'sorties-origine-2026-09-28.json', 'l\'épingle d\'origine reste celle du code d\'astro-pro') assert.equal(correctifsPin.epingle_origine, PIN_ORIGINE) const { ecarts } = calculeEcartsCorrectifs() assert.deepEqual(ecarts, correctifsPin.ecarts, 'les écarts avec l\'origine ont changé : ce n\'est voulu que pour corriger un défaut du noyau, avec test ; refiger avec node scripts/ecarts-correctifs.mjs --ecrire et le dire dans le commit') // Un correctif ne fait jamais apparaître un « budget insuffisant » là où l'origine n'en avait pas, // ni un total hors de la bande ±5 % en mode global. for (const e of ecarts) { if (e.champ === 'status') assert.notEqual(e.corrige, 'BUDGET_INSUFFISANT', `${e.profil}/${e.mode}/${e.axe}`) if (e.champ === 'ecart_budget_pct') assert.ok(Math.abs(e.corrige) <= 5, `${e.profil} : écart global ${e.corrige} %`) } // Ce que l'origine avait de juste reste : budgets alloués et poids des axes, inchangés for (const profil of profils) { const attendu = pin.profils[profil.nom].global const r = autonomie.noyau.selectionner(profil.inputs, options, postes, france_climat, { config: algo_config, coefficients: origine }, false) for (const axe of AXES) { assert.equal(r.axes[axe].budget_alloue, attendu.axes[axe].budget_alloue, `${profil.nom}/${axe} : budget alloué`) assert.equal(r.axes[axe].poids, attendu.axes[axe].poids, `${profil.nom}/${axe} : poids`) } } }) test('moteur d\'axe = mode « un seul axe actif » de selectionner() de ce paquet, à budget d\'axe égal', () => { for (const profil of profils) { const contexte = autonomie.noyau.selectionner(profil.inputs, options, postes, france_climat, { config: algo_config, coefficients: origine }, false).contexte for (const axe of AXES) { const budget = pin.profils[profil.nom].par_axe[axe].budget_axe const actifs = Object.fromEntries(AXES.map((a) => [a, a === axe])) const attendu = autonomie.noyau.selectionner({ ...profil.inputs, budget_eur: budget }, options, postes, france_climat, { config: autonomie.noyau.configPourAxes(algo_config, actifs), coefficients: origine }, false) const r = MOTEURS[axe].calculer({ autodiag: profil.inputs, budget_axe: budget, contexte }, { postes, options, algo_config, coefficients: origine }) assert.equal(r.sorties.axe, axe) assert.equal(r.sorties.sous_total_eur_ht, attendu.axes[axe].sous_total_eur_ht, `${profil.nom}/${axe} : sous-total`) assert.equal(r.sorties.status, attendu.status, `${profil.nom}/${axe} : statut`) assert.deepEqual(resumePostes(r.sorties.postes), resumePostes(attendu.axes[axe].postes), `${profil.nom}/${axe} : postes`) assert.deepEqual(r.sorties.couplages, attendu.couplages, `${profil.nom}/${axe} : journal des couplages`) assert.ok(!r.avertissements.some((a) => /inter-axes/.test(a)), 'aucun couplage inter-axes dans les données') } } }) test('intégrateur mode global = selectionner() ; mode par_axe = somme des moteurs d\'axe', () => { for (const profil of profils) { const donnees = { postes, options, algo_config, france_climat, coefficients: origine } const g = integrateur.calculer({ autodiag: profil.inputs, budget_total: profil.inputs.budget_eur, mode: 'global' }, donnees) const attendu = autonomie.noyau.selectionner(profil.inputs, options, postes, france_climat, { config: algo_config, coefficients: origine }, false) assert.equal(g.sorties.mode, 'global') assert.equal(g.sorties.total_eur_ht, attendu.total_eur_ht) assert.equal(g.sorties.status, attendu.status) for (const axe of AXES) assert.deepEqual(g.sorties.axes[axe].typologies, attendu.axes[axe].postes.map((p) => p.typologie_choisie)) const p = integrateur.calculer({ autodiag: profil.inputs, budget_total: profil.inputs.budget_eur, mode: 'par_axe' }, donnees) assert.equal(p.sorties.mode, 'par_axe') assert.deepEqual(p.sorties.budgets, g.sorties.budgets, 'même allocation de budget dans les deux modes') let somme = 0 for (const axe of AXES) { const a = MOTEURS[axe].calculer({ autodiag: profil.inputs, budget_axe: g.sorties.budgets[axe], contexte: g.sorties.contexte }, donnees).sorties assert.deepEqual(p.sorties.axes[axe].typologies, a.typologies, `${profil.nom}/${axe} : par_axe = moteur d'axe`) somme += p.sorties.axes[axe].sous_total_eur_ht } assert.equal(p.sorties.total_eur_ht, somme) assert.equal(p.version, g.version) } }) test('table harmonisée : écarts avec l\'origine figés (sélection et taux, trois profils)', () => { const { ecarts } = calculeEcarts() assert.deepEqual(ecarts, ecartsPin.ecarts, 'les écarts ont changé : relire docs/CONSTANTES.md puis refiger avec npm run ecarts -- --ecrire ') // Ce que l'harmonisation change sur les trois profils : aucune typologie, seulement les taux. assert.ok(ecarts.every((e) => e.mode.startsWith('taux')), 'sur les trois profils de référence, seule la couverture change') assert.notDeepEqual(COEFFICIENTS.orientation_pv.valeur, origine.orientation_pv.valeur, 'la table harmonisée diffère bien de l\'origine') })