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
110 lines
6.5 KiB
JavaScript
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)
|
|
})
|