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:
Jules
2026-09-28 17:06:49 +02:00
co-authored by Claude Fable 5.1
commit 69809df5d1
123 changed files with 13225 additions and 0 deletions
+127
View File
@@ -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')
})