Files
moteurs/test/moteurs.test.mjs
T
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

110 lines
6.5 KiB
JavaScript

// 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)
})