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
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
// Tests des moteurs et helpers communs : contexte (Corse, DOM, inconnu),
|
||||
// géométrie (maison B de l'étude), argent, validation des données.
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { contexte, departementDepuisCodePostal, geometrie, mensualite, annuite, coutDuCredit, arrondiAuPas, additionneFourchettes, valider, VERSION, COEFFICIENTS } from '../dist/index.js'
|
||||
|
||||
const lire = (f) => JSON.parse(readFileSync(new URL(`../fixtures/${f}`, import.meta.url), 'utf8'))
|
||||
const france_climat = lire('france_climat.json')
|
||||
|
||||
test('contexte : département depuis code postal (Corse 2A/2B, DOM 97x, métropole)', () => {
|
||||
assert.equal(departementDepuisCodePostal('20000'), '2A')
|
||||
assert.equal(departementDepuisCodePostal('20190'), '2A')
|
||||
assert.equal(departementDepuisCodePostal('20200'), '2B')
|
||||
assert.equal(departementDepuisCodePostal('20620'), '2B')
|
||||
assert.equal(departementDepuisCodePostal('20999'), '2A')
|
||||
assert.equal(departementDepuisCodePostal('97100'), '971')
|
||||
assert.equal(departementDepuisCodePostal('97600'), '976')
|
||||
assert.equal(departementDepuisCodePostal('75019'), '75')
|
||||
assert.equal(departementDepuisCodePostal(31000), '31')
|
||||
assert.equal(departementDepuisCodePostal(''), null)
|
||||
assert.equal(departementDepuisCodePostal(null), null)
|
||||
})
|
||||
|
||||
test('contexte : 2A, 2B et 971 tombent sur leur ligne france_climat (H3), pas sur le défaut H2a', () => {
|
||||
for (const [cp, dept] of [
|
||||
['20000', '2A'],
|
||||
['20200', '2B'],
|
||||
['97100', '971'],
|
||||
]) {
|
||||
const r = contexte.calculer({ code_postal: cp }, { france_climat })
|
||||
const ligne = france_climat.find((c) => c.dept === dept)
|
||||
assert.ok(ligne, `fixture : ligne ${dept} attendue`)
|
||||
assert.equal(r.sorties.departement, dept)
|
||||
assert.equal(r.sorties.zone_climatique_rt, ligne.zone_rt)
|
||||
assert.equal(r.sorties.zone_climatique_rt, 'H3', `${dept} est en H3 dans la fixture ; l'origine donnait H2a`)
|
||||
assert.equal(r.version, VERSION)
|
||||
assert.ok(r.sources.some((s) => s.cle === `france_climat.${dept}`), 'la ligne climat est tracée en source')
|
||||
if (ligne.pluvio_mm_an != null) {
|
||||
assert.equal(r.sorties.pluviometrie_mm, ligne.pluvio_mm_an)
|
||||
assert.equal(r.sorties.dju, ligne.dju_base_18)
|
||||
assert.deepEqual(r.avertissements, [], `${cp} : aucun défaut appliqué`)
|
||||
} else {
|
||||
// DOM : la fixture ne porte ni pluviométrie ni DJU (« DOM tropical ») → défauts déclarés
|
||||
assert.equal(r.sorties.pluviometrie_mm, COEFFICIENTS.contexte_defaut.valeur.pluviometrie_mm)
|
||||
assert.ok(r.avertissements.some((a) => /pluviométrie absente/.test(a)), `${cp} : pluviométrie par défaut déclarée`)
|
||||
assert.ok(r.avertissements.some((a) => /DJU absent/.test(a)), `${cp} : DJU par défaut déclaré`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('contexte : code postal inconnu → défaut déclaré en avertissement et en source', () => {
|
||||
const r = contexte.calculer({ code_postal: '98000' }, { france_climat })
|
||||
assert.equal(r.sorties.zone_climatique_rt, COEFFICIENTS.contexte_defaut.valeur.zone_rt)
|
||||
assert.equal(r.avertissements.length, 1)
|
||||
assert.match(r.avertissements[0], /défaut/)
|
||||
assert.ok(r.sources.some((s) => s.cle === 'contexte_defaut'))
|
||||
})
|
||||
|
||||
test('géométrie : maison B de l\'étude (Tournefeuille, emprise 73 m², 2 niveaux, 5 m) — SHAB estimée à confronter au DPE en P1.1', () => {
|
||||
const r = geometrie.calculer({ emprise_m2: 73, niveaux: 2, hauteur_m: 5, pente_toit_deg: 30, mitoyennete: 0 }, {})
|
||||
const s = r.sorties
|
||||
// SHAB = 73 × 2 × 0,80 = 116,8 m²
|
||||
assert.equal(s.shab_m2, 116.8)
|
||||
assert.equal(s.shab_estimee, true)
|
||||
assert.equal(s.plancher_bas_m2, 73)
|
||||
assert.equal(s.hauteur_etage_m, 2.5)
|
||||
assert.equal(s.volume_m3, 292)
|
||||
// périmètre 4 × √(73 × 1,3) = 38,97 m ; murs ext × 5 m = 194,83 m² ; vitrage 116,8/6 = 19,47 ; opaques 175,37
|
||||
assert.equal(s.perimetre_m, 38.97)
|
||||
assert.equal(s.murs_exterieurs_m2, 194.83)
|
||||
assert.equal(s.vitrage_m2, 19.47)
|
||||
assert.equal(s.murs_opaques_m2, 175.37)
|
||||
// toiture 73 / cos 30° × 1,05 = 88,51
|
||||
assert.equal(s.toiture_m2, 88.51)
|
||||
assert.ok(r.avertissements.some((a) => /estimée/.test(a)), 'SHAB estimée déclarée')
|
||||
assert.ok(r.sources.some((x) => x.cle === 'geometrie_ratio_shab_emprise'))
|
||||
// La surface habitable du DPE 2631E0015736Q n'est pas dans l'étude (aucune recherche web dans ce lot) :
|
||||
// l'écart SHAB estimée vs DPE se mesure au premier appel de l'adaptateur DPE, P1.1.
|
||||
console.log(` maison B : SHAB estimée ${s.shab_m2} m² (ratio 0,80) — surface DPE : à lire en P1.1 (écart visé < 20 %)`)
|
||||
})
|
||||
|
||||
test('géométrie : mitoyenneté et surface connue', () => {
|
||||
const seul = geometrie.calculer({ emprise_m2: 100, niveaux: 1, hauteur_m: 2.5 }, {}).sorties
|
||||
const mitoyen = geometrie.calculer({ emprise_m2: 100, niveaux: 1, hauteur_m: 2.5, mitoyennete: 2 }, {}).sorties
|
||||
assert.equal(mitoyen.perimetre_expose_m, +(seul.perimetre_m * 0.5).toFixed(2))
|
||||
assert.ok(mitoyen.murs_exterieurs_m2 < seul.murs_exterieurs_m2)
|
||||
const connue = geometrie.calculer({ emprise_m2: 100, niveaux: 1, hauteur_m: 2.5, surface_habitable_m2: 95 }, {})
|
||||
assert.equal(connue.sorties.shab_m2, 95)
|
||||
assert.equal(connue.sorties.shab_estimee, false)
|
||||
assert.ok(!connue.avertissements.some((a) => /estimée/.test(a)))
|
||||
const sansRien = geometrie.calculer({ emprise_m2: 80 }, {})
|
||||
assert.equal(sansRien.avertissements.length, 4, 'niveaux, hauteur, pente inconnus + SHAB estimée → quatre avertissements')
|
||||
})
|
||||
|
||||
test('argent : mensualité, annuité, coût du crédit, arrondis, fourchettes', () => {
|
||||
// 10 000 € à 3 % sur 10 ans → 96,56 €/mois (formule d'annuité constante)
|
||||
assert.equal(+mensualite(10000, 3, 10).toFixed(2), 96.56)
|
||||
assert.equal(+annuite(10000, 3, 10).toFixed(2), 1158.73)
|
||||
assert.equal(+coutDuCredit(10000, 3, 10).toFixed(2), 1587.29)
|
||||
assert.equal(mensualite(12000, 0, 10), 100)
|
||||
assert.equal(mensualite(0, 3, 10), 0)
|
||||
assert.equal(arrondiAuPas(1234, 50), 1250)
|
||||
assert.equal(arrondiAuPas(7, 0), 7)
|
||||
assert.deepEqual(additionneFourchettes([{ min: 10, max: 20 }, { min: null, max: 5 }, { min: 3, max: null }, { min: null, max: null }]), { min: 18, max: 28 })
|
||||
})
|
||||
|
||||
test('valider : les fixtures passent, une donnée cassée est refusée avec son chemin', () => {
|
||||
for (const [table, fichier] of [
|
||||
['postes', 'postes.json'],
|
||||
['options', 'options.json'],
|
||||
['france_climat', 'france_climat.json'],
|
||||
['algo_config', 'algo-config.json'],
|
||||
]) {
|
||||
const v = valider(table, lire(fichier))
|
||||
assert.deepEqual(v.erreurs, [], `${table} : fixture valide`)
|
||||
assert.equal(v.ok, true)
|
||||
}
|
||||
const cassees = lire('postes.json').map((p, i) => (i === 2 ? { ...p, axe: 'feu', Id: 'trois' } : p))
|
||||
const v = valider('postes', cassees)
|
||||
assert.equal(v.ok, false)
|
||||
assert.ok(v.erreurs.some((e) => e.startsWith('postes[2].Id')), 'chemin de l\'erreur')
|
||||
assert.ok(v.erreurs.some((e) => /postes\[2\]\.axe.*feu/.test(e)))
|
||||
assert.equal(valider('postes', {}).ok, false)
|
||||
assert.equal(valider('postes', []).ok, false, 'minItems 1')
|
||||
assert.equal(valider('algo_config', { poids_axes_base: { eau: 40, energie: 40, alim: 20 } }).ok, false, 'clés requises manquantes')
|
||||
})
|
||||
@@ -0,0 +1,96 @@
|
||||
// Non-régression du découpage par axe (LOT P1.0 point 4, amendé le 28/09).
|
||||
//
|
||||
// 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
|
||||
// même sélection que 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.
|
||||
// 2. Chaque moteur d'axe, à budget d'axe égal, rend la sélection du mode
|
||||
// « un seul axe actif » de selectionner() d'origine (même épingle).
|
||||
// 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-2026-09-28.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'
|
||||
|
||||
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-09-28.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, coefficients d\'origine, trois profils', () => {
|
||||
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)
|
||||
assert.equal(r.total_eur_ht, attendu.total_eur_ht, `${profil.nom} : total`)
|
||||
assert.equal(r.status, attendu.status, `${profil.nom} : statut`)
|
||||
assert.equal(r.ecart_budget_pct, attendu.ecart_budget_pct, `${profil.nom} : écart`)
|
||||
assert.deepEqual(r.couplages, attendu.couplages, `${profil.nom} : journal des couplages`)
|
||||
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`)
|
||||
assert.equal(r.axes[axe].sous_total_eur_ht, attendu.axes[axe].sous_total_eur_ht, `${profil.nom}/${axe} : sous-total`)
|
||||
assert.deepEqual(resumePostes(r.axes[axe].postes), attendu.axes[axe].postes, `${profil.nom}/${axe} : postes`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('moteur d\'axe = mode « un seul axe actif » de selectionner() d\'origine, à 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 attendu = pin.profils[profil.nom].par_axe[axe]
|
||||
const r = MOTEURS[axe].calculer({ autodiag: profil.inputs, budget_axe: attendu.budget_axe, 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), attendu.axes[axe].postes, `${profil.nom}/${axe} : postes`)
|
||||
assert.deepEqual(r.sorties.typologies, attendu.axes[axe].postes.map((p) => p.typologie_choisie))
|
||||
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 = pin.profils[profil.nom].global
|
||||
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 = pin.profils[profil.nom].par_axe[axe]
|
||||
assert.deepEqual(p.sorties.axes[axe].typologies, a.axes[axe].postes.map((x) => x.typologie_choisie), `${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 <date>')
|
||||
// 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')
|
||||
})
|
||||
@@ -0,0 +1,109 @@
|
||||
// Le contrat d'un moteur (SPEC §4.1) tenu par les moteurs d'axe et l'intégrateur :
|
||||
// déterminisme, version, sources (coefficients lus + prix des typologies), avertissements.
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { eau, energie, alimentation, integrateur, contexte, VERSION, COEFFICIENTS, autonomie } 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 B = profils[1]
|
||||
const ctxB = contexte.calculer({ code_postal: B.inputs.code_postal }, { france_climat }).sorties
|
||||
|
||||
test('déterminisme : deux appels identiques, même résultat octet pour octet', () => {
|
||||
for (const m of [eau, energie, alimentation]) {
|
||||
const a = m.calculer({ autodiag: B.inputs, budget_axe: 14000, contexte: ctxB }, donnees)
|
||||
const b = m.calculer({ autodiag: B.inputs, budget_axe: 14000, contexte: ctxB }, donnees)
|
||||
assert.equal(JSON.stringify(a), JSON.stringify(b), m.id)
|
||||
}
|
||||
const a = integrateur.calculer({ autodiag: B.inputs, budget_total: 35000, mode: 'par_axe' }, donnees)
|
||||
const b = integrateur.calculer({ autodiag: B.inputs, budget_total: 35000, mode: 'par_axe' }, donnees)
|
||||
assert.equal(JSON.stringify(a), JSON.stringify(b))
|
||||
})
|
||||
|
||||
test('les données reçues ne sont pas mutées par un calcul', () => {
|
||||
const avant = JSON.stringify({ options, postes, algo_config, france_climat, COEFFICIENTS })
|
||||
integrateur.calculer({ autodiag: B.inputs, budget_total: 35000, mode: 'global' }, donnees)
|
||||
eau.calculer({ autodiag: B.inputs, budget_axe: 14000, contexte: ctxB }, donnees)
|
||||
assert.equal(JSON.stringify({ options, postes, algo_config, france_climat, COEFFICIENTS }), avant)
|
||||
})
|
||||
|
||||
test('chaque résultat porte la version, les coefficients lus et les prix des typologies retenues', () => {
|
||||
const r = eau.calculer({ autodiag: B.inputs, budget_axe: 14000, contexte: ctxB }, donnees)
|
||||
assert.equal(r.version, VERSION)
|
||||
assert.equal(r.sorties.axe, 'eau')
|
||||
assert.ok(r.sorties.postes.length >= 4, 'eau : au moins quatre postes obligatoires')
|
||||
const cles = r.sources.map((s) => s.cle)
|
||||
assert.ok(cles.includes('captage_rendement'), 'le rendement de captage est lu (scoreur et taux)')
|
||||
assert.ok(cles.includes('besoin_eau_l_j_pers') && cles.includes('part_eau_substituable'))
|
||||
for (const p of r.sorties.postes) assert.ok(cles.includes(`option.${p.typologie_choisie}`), `prix de ${p.typologie_choisie} tracé`)
|
||||
for (const s of r.sources) {
|
||||
assert.ok(s.source, `${s.cle} : source`)
|
||||
assert.ok(typeof s.date === 'string', `${s.cle} : date`)
|
||||
}
|
||||
assert.equal(typeof r.sorties.taux_couverture, 'number')
|
||||
assert.ok(r.sorties.taux_detail && r.sorties.taux_detail.captable_m3 > 0)
|
||||
})
|
||||
|
||||
test('énergie : l\'autoconsommation est appliquée au taux de couverture (0,30 sans batterie, 0,70 avec 10 kWh)', () => {
|
||||
const r = energie.calculer({ autodiag: { ...B.inputs, orientation_toit: 'sud' }, budget_axe: 14000, contexte: ctxB }, donnees)
|
||||
const d = r.sorties.taux_detail
|
||||
assert.ok(d && d.kwc > 0, 'un kit PV est retenu à ce budget')
|
||||
const attendu = d.batterie_kwh >= 10 ? 0.7 : d.batterie_kwh <= 0 ? 0.3 : null
|
||||
if (attendu != null) assert.equal(d.taux_autoconso, attendu)
|
||||
assert.equal(d.autoconsomme_kwh, Math.round(d.production_kwh * d.taux_autoconso))
|
||||
assert.equal(d.kwh_par_kwc, COEFFICIENTS.productible_pv_kwh_kwc.valeur.table[ctxB.zone_climatique_rt])
|
||||
const cles = r.sources.map((s) => s.cle)
|
||||
for (const c of ['orientation_pv', 'productible_pv_kwh_kwc', 'autoconso_sans_batterie', 'autoconso_avec_batterie_10kwh', 'besoin_elec_kwh_pers_an']) assert.ok(cles.includes(c), c)
|
||||
assert.ok(!r.avertissements.some((a) => /H2d/.test(a)), 'B est en H2c : pas d\'avertissement H2d')
|
||||
})
|
||||
|
||||
test('énergie : zone H2d → avertissement « interpolé » ; orientation inconnue → avertissement', () => {
|
||||
const ctx = contexte.calculer({ code_postal: '07000' }, { france_climat }).sorties
|
||||
assert.equal(ctx.zone_climatique_rt, 'H2d')
|
||||
const r = energie.calculer({ autodiag: { ...B.inputs, code_postal: '07000', orientation_toit: 'nord-ouest' }, budget_axe: 14000, contexte: ctx }, donnees)
|
||||
if (r.sorties.taux_couverture != null) {
|
||||
assert.ok(r.avertissements.some((a) => /H2d/.test(a)))
|
||||
assert.ok(r.avertissements.some((a) => /orientation/.test(a)))
|
||||
}
|
||||
})
|
||||
|
||||
test('alimentation : parcelle trop petite → budget 0 → aucun poste, taux null, statut OK', () => {
|
||||
const A = profils[0]
|
||||
const ctxA = contexte.calculer({ code_postal: A.inputs.code_postal }, { france_climat }).sorties
|
||||
const r = alimentation.calculer({ autodiag: A.inputs, budget_axe: 0, contexte: ctxA }, donnees)
|
||||
assert.deepEqual(r.sorties.postes, [])
|
||||
assert.equal(r.sorties.taux_couverture, null)
|
||||
assert.equal(r.sorties.status, 'OK')
|
||||
})
|
||||
|
||||
test('intégrateur : aménagements cochés ignorés et signalés (P1.5) ; axes désactivés respectés', () => {
|
||||
const r = integrateur.calculer({ autodiag: B.inputs, budget_total: 35000, mode: 'par_axe', amenagements_coches: ['x'], axes_actifs: { alim: false } }, donnees)
|
||||
assert.ok(r.avertissements.some((a) => /P1\.5/.test(a)))
|
||||
assert.equal(r.sorties.budgets.alim, 0)
|
||||
assert.deepEqual(r.sorties.axes.alim.postes, [])
|
||||
assert.ok(r.sorties.axes.eau.postes.length > 0 && r.sorties.axes.energie.postes.length > 0)
|
||||
assert.equal(r.sorties.poids.eau + r.sorties.poids.energie + r.sorties.poids.alim, 100)
|
||||
})
|
||||
|
||||
test('code postal inconnu : l\'avertissement du contexte remonte dans l\'intégrateur', () => {
|
||||
const r = integrateur.calculer({ autodiag: { ...B.inputs, code_postal: '98000' }, budget_total: 35000 }, donnees)
|
||||
assert.ok(r.avertissements.some((a) => /contexte par défaut/.test(a)))
|
||||
assert.equal(r.sorties.contexte.departement, '98')
|
||||
})
|
||||
|
||||
test('noyau : configPourAxes ne mute pas la config ; parseParam / parseJsonArr tolèrent objet et chaîne', () => {
|
||||
const avant = JSON.stringify(algo_config)
|
||||
autonomie.noyau.configPourAxes(algo_config, { eau: false })
|
||||
assert.equal(JSON.stringify(algo_config), avant)
|
||||
assert.deepEqual(autonomie.noyau.parseJsonArr('["a","b"]'), ['a', 'b'])
|
||||
assert.deepEqual(autonomie.noyau.parseJsonArr(['a']), ['a'])
|
||||
assert.deepEqual(autonomie.noyau.parseJsonArr('null'), [])
|
||||
assert.deepEqual(autonomie.noyau.parseParam('{"strategie":"fixe","default":3}'), { strategie: 'fixe', default: 3 })
|
||||
assert.equal(autonomie.noyau.parseParam('{oops'), null)
|
||||
})
|
||||
@@ -0,0 +1,279 @@
|
||||
// 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');
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
// Tests du paquet lui-même : version, schémas en phase, tables de coefficients
|
||||
// bien formées, pureté du code (aucune I/O, aucune horloge, aucun aléa dans
|
||||
// src/), et absence des anciennes constantes dupliquées (LOT P1.0 point 6).
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync, readdirSync, statSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { VERSION, SCHEMAS, NOMS_TABLES, COEFFICIENTS, verifieTableCoefficients, MOTEURS } from '../dist/index.js'
|
||||
|
||||
const racine = fileURLToPath(new URL('..', import.meta.url))
|
||||
const lire = (rel) => readFileSync(join(racine, rel), 'utf8')
|
||||
|
||||
function fichiersTs(dossier) {
|
||||
const out = []
|
||||
for (const nom of readdirSync(dossier)) {
|
||||
const chemin = join(dossier, nom)
|
||||
if (statSync(chemin).isDirectory()) out.push(...fichiersTs(chemin))
|
||||
else if (nom.endsWith('.ts')) out.push(chemin)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Lignes de code hors commentaires (// et /* *\/ sur une ligne) et hors chaînes de `note:`/`source:`. */
|
||||
function lignesDeCode(texte) {
|
||||
return texte
|
||||
.split('\n')
|
||||
.map((l) => l.replace(/\/\/.*$/, '').replace(/\/\*.*?\*\//g, ''))
|
||||
.filter((l) => l.trim() && !/^\s*\*/.test(l) && !/^\s*(note|source):\s*['"`]/.test(l))
|
||||
}
|
||||
|
||||
test('VERSION du code = version de package.json', () => {
|
||||
const pkg = JSON.parse(lire('package.json'))
|
||||
assert.equal(VERSION, pkg.version)
|
||||
assert.equal(pkg.name, '@transformer/moteurs')
|
||||
assert.deepEqual(pkg.dependencies ?? {}, {}, 'zéro dépendance de production')
|
||||
})
|
||||
|
||||
test('schemas/*.schema.json en phase avec src/commun/schemas.ts', () => {
|
||||
for (const table of NOMS_TABLES) {
|
||||
const json = JSON.parse(lire(`schemas/${table}.schema.json`))
|
||||
delete json.$schema
|
||||
assert.deepEqual(json, JSON.parse(JSON.stringify(SCHEMAS[table])), `${table}.schema.json diffère de SCHEMAS.${table} : relancer npm run build`)
|
||||
}
|
||||
})
|
||||
|
||||
test('les deux tables de coefficients sont complètes, sourcées et datées', () => {
|
||||
assert.deepEqual(verifieTableCoefficients(COEFFICIENTS), [])
|
||||
const origine = JSON.parse(lire('fixtures/coefficients-2026-05.json'))
|
||||
assert.deepEqual(verifieTableCoefficients(origine), [])
|
||||
for (const cle of Object.keys(COEFFICIENTS)) assert.ok(cle in origine, `fixture d'origine : clé ${cle} manquante`)
|
||||
})
|
||||
|
||||
test('six moteurs livrés, identifiants stables', () => {
|
||||
assert.deepEqual([...MOTEURS], ['commun.contexte', 'commun.geometrie', 'autonomie.eau', 'autonomie.energie', 'autonomie.alimentation', 'autonomie.integrateur'])
|
||||
})
|
||||
|
||||
test('pureté : src/ ne lit ni réseau, ni disque, ni horloge, ni DOM, ni aléa, ni console', () => {
|
||||
const interdits = [
|
||||
[/\bfetch\s*\(/, 'fetch'],
|
||||
[/\brequire\s*\(/, 'require'],
|
||||
[/from\s+['"]node:/, 'import node:'],
|
||||
[/from\s+['"](fs|path|os|http|https|child_process)['"]/, 'module Node'],
|
||||
[/\bprocess\./, 'process'],
|
||||
[/\bDate\.now\b|\bnew Date\b/, 'horloge'],
|
||||
[/\bMath\.random\b/, 'aléa'],
|
||||
[/\b(localStorage|sessionStorage|document|window)\b/, 'DOM'],
|
||||
[/\bconsole\./, 'console'],
|
||||
[/\bimport\s.*\.json['"]/, 'import JSON (les données s\'injectent)'],
|
||||
]
|
||||
const fautes = []
|
||||
for (const f of fichiersTs(join(racine, 'src'))) {
|
||||
const lignes = lignesDeCode(readFileSync(f, 'utf8'))
|
||||
for (const [re, nom] of interdits) {
|
||||
const l = lignes.find((x) => re.test(x))
|
||||
if (l) fautes.push(`${f.slice(racine.length)} : ${nom} — ${l.trim()}`)
|
||||
}
|
||||
}
|
||||
assert.deepEqual(fautes, [])
|
||||
})
|
||||
|
||||
test('anciennes constantes : plus aucune définition en double dans src/', () => {
|
||||
// Chaque grandeur existe UNE fois, dans coefficients.ts (LOT P1.0 point 6).
|
||||
const patrons = [
|
||||
[/ORIENTATION_FACTORS|DJU_DEFAULTS_BY_ZONE|FALLBACK_PVGIS|RENDEMENT_CAPTAGE|BESOIN_EAU_L_PERS_AN|PART_SUBSTITUABLE|BESOIN_ELEC_KWH_PERS_AN|ML_HAIE_PAR_PERS/, 'constante de module d\'origine'],
|
||||
[/\b0\.93\b|\b0\.78\b|\b0\.40\b/, 'facteurs d\'orientation d\'origine'],
|
||||
[/\b1550\b|\b1480\b|\b54750\b/, 'productible 1 550 / 1 480 ou besoin 54 750'],
|
||||
[/\(pluvio \/ 1000\) \* 0\.7\b/, 'rendement de captage 0,7 dans score_pluviometrie'],
|
||||
[/\b0\.85\b/, 'rendement 0,85'],
|
||||
]
|
||||
const fautes = []
|
||||
for (const f of fichiersTs(join(racine, 'src'))) {
|
||||
if (f.endsWith('coefficients.ts')) continue
|
||||
const lignes = lignesDeCode(readFileSync(f, 'utf8'))
|
||||
for (const [re, nom] of patrons) {
|
||||
const l = lignes.find((x) => re.test(x))
|
||||
if (l) fautes.push(`${f.slice(racine.length)} : ${nom} — ${l.trim()}`)
|
||||
}
|
||||
}
|
||||
assert.deepEqual(fautes, [])
|
||||
// Dans coefficients.ts, les anciennes valeurs n'apparaissent qu'en note (chaîne), jamais en valeur.
|
||||
const coefs = lignesDeCode(lire('src/commun/coefficients.ts')).join('\n')
|
||||
assert.ok(!/valeur:\s*0\.85\b/.test(coefs) && !/\b1550\b/.test(coefs) && !/nord:\s*0\.4\b/.test(coefs), 'coefficients.ts porte une valeur d\'origine')
|
||||
})
|
||||
Reference in New Issue
Block a user