From e66523a935628a292cbe12f4d208aeba0764d49b Mon Sep 17 00:00:00 2001 From: Florent Perinel Date: Mon, 13 Jul 2026 11:41:35 +0200 Subject: [PATCH 01/20] test: add unit test for b value --- src/3.1_b.js | 2 +- src/3.1_b.spec.js | 182 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 src/3.1_b.spec.js diff --git a/src/3.1_b.js b/src/3.1_b.js index 12a3f75..4ab5520 100644 --- a/src/3.1_b.js +++ b/src/3.1_b.js @@ -1,7 +1,7 @@ import enums from './enums.js'; import { tv, requestInput, requestInputID, bug_for_bug_compat } from './utils.js'; -function findRanges(inputNumber) { +export function findRanges(inputNumber) { const ranges = [0.25, 0.5, 0.75, 1, 1.25, 2, 2.5, 3, 3.5, 4, 6, 8, 10, 25, 50]; const result = []; diff --git a/src/3.1_b.spec.js b/src/3.1_b.spec.js new file mode 100644 index 0000000..b324691 --- /dev/null +++ b/src/3.1_b.spec.js @@ -0,0 +1,182 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +/** + * Dépendances mockées pour isoler la logique de `3.1_b.js` : + * - `tv` : accès à la table de valeurs coef_reduction_deperdition (on contrôle la ligne retournée) ; + * - `requestInput` / `requestInputID` : simples passe-plats vers les données d'entrée ; + * - `bug_for_bug_compat` : désactivé pour isoler le comportement nominal ; + * - `enums` : mapping minimal nécessaire au calcul. + */ +vi.mock('./utils.js', () => ({ + tv: vi.fn(), + requestInputID: (de, du, field) => { + const enumName = `enum_${field}_id`; + du[enumName] = de[enumName]; + return de[enumName]; + }, + requestInput: (de, du, field) => de[field], + bug_for_bug_compat: false +})); + +vi.mock('./enums.js', () => ({ + default: { + zone_climatique: { 1: 'h1a', 2: 'h1b', 8: 'h3' }, + type_adjacence: { 8: 'garage', 9: 'cellier', 10: 'espace tampon', 12: 'comble', 21: 'autres' }, + cfg_isolation_lnc: { 1: 'local chauffé non accessible', 2: 'lc non isolé + lnc non isolé' } + } +})); + +const { default: b, findRanges } = await import('./3.1_b.js'); +const { tv } = await import('./utils.js'); + +const TABLE = 'coef_reduction_deperdition'; + +beforeEach(() => { + tv.mockReset(); +}); + +/** + * 3.1 Coefficient de réduction des déperditions (b) + * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §3.1 + */ +describe('findRanges - encadrement du rapport surface_aiu / surface_aue', () => { + const ranges = [0.25, 0.5, 0.75, 1, 1.25, 2, 2.5, 3, 3.5, 4, 6, 8, 10, 25, 50]; + + test('valeur inférieure à la plus petite borne : borne inférieure nulle', () => { + expect(findRanges(0.1)).toEqual([null, 0.25]); + }); + + test('valeur supérieure à la plus grande borne : borne supérieure nulle', () => { + expect(findRanges(60)).toEqual([50, null]); + }); + + test('valeur strictement comprise entre deux bornes : encadrement [inf, sup]', () => { + expect(findRanges(0.3)).toEqual([0.25, 0.5]); + expect(findRanges(1.1)).toEqual([1, 1.25]); + expect(findRanges(9)).toEqual([8, 10]); + }); + + test('valeur égale à une borne intermédiaire : encadrement [borne précédente, borne]', () => { + expect(findRanges(0.5)).toEqual([0.25, 0.5]); + expect(findRanges(1)).toEqual([0.75, 1]); + expect(findRanges(50)).toEqual([25, 50]); + }); + + test('valeur égale à la première borne : un seul élément (pas de borne précédente)', () => { + // i === 0 : la boucle ne pousse pas de borne inférieure + expect(findRanges(0.25)).toEqual([0.25]); + }); + + test('toutes les bornes exactes retournent un encadrement cohérent', () => { + ranges.forEach((value, index) => { + const result = findRanges(value); + if (index === 0) { + expect(result).toEqual([0.25]); + } else { + expect(result).toEqual([ranges[index - 1], value]); + } + }); + }); +}); + +describe('b - calcul du coefficient de réduction des déperditions', () => { + /** + * Local chauffé non accessible / non déperditif avec surface_aue nulle (cf. page 10) : + * b = 0 et aucun accès à la table de valeurs. + */ + test.each([ + ['8', 'garage'], + ['9', 'cellier'], + ['12', 'comble faiblement ventilé'], + ['21', 'autres dépendances'] + ])( + 'surface_aue nulle pour une adjacence de type %s (%s) : b = 0 sans accès table', + (adjacence) => { + const di = {}; + const de = { enum_type_adjacence_id: adjacence, surface_aue: 0 }; + b(di, de, {}, 1); + expect(di.b).toBe(0); + expect(tv).not.toHaveBeenCalled(); + } + ); + + /** + * Espace tampon solarisé (adjacence 10) : le matcher inclut la zone climatique (2 premiers + * caractères) et la configuration d'isolation du local non chauffé. + */ + test('espace tampon solarisé (adjacence 10) : matcher zone climatique + cfg isolation', () => { + tv.mockReturnValue({ b: '0.58', tv_coef_reduction_deperdition_id: '269' }); + const di = {}; + const de = { enum_type_adjacence_id: '10', enum_cfg_isolation_lnc_id: '7' }; + + b(di, de, {}, 1); + + expect(tv).toHaveBeenCalledWith(TABLE, { + enum_type_adjacence_id: '10', + zone_climatique: 'h1', + enum_cfg_isolation_lnc_id: '7' + }); + expect(di.b).toBe(0.58); + expect(de.tv_coef_reduction_deperdition_id).toBe(269); + }); + + /** + * Adjacence de type garage avec surface_aiu === surface_aue : + * bornes par défaut 0,75 < aiu/aue ≤ 1,00. + */ + test('garage (adjacence 8), surface_aiu === surface_aue : bornes par défaut', () => { + tv.mockReturnValue({ b: '0.7', tv_coef_reduction_deperdition_id: '100' }); + const di = {}; + const de = { + enum_type_adjacence_id: '8', + enum_cfg_isolation_lnc_id: '2', + surface_aiu: 20, + surface_aue: 20 + }; + + b(di, de, {}, 1); + + expect(tv).toHaveBeenCalledWith(TABLE, { + enum_type_adjacence_id: '8', + enum_cfg_isolation_lnc_id: '2', + aiu_aue_min: '0,75 <', + aiu_aue_max: '≤ 1,00' + }); + expect(di.b).toBe(0.7); + }); + + /** + * Adjacence de type garage avec rapport surface_aiu / surface_aue = 0.5 : + * findRanges([0.25, 0.5]) => bornes '0,25 <' et '≤ 0,50'. + */ + test('garage (adjacence 8), rapport surface_aiu / surface_aue = 0.5 : bornes encadrantes', () => { + tv.mockReturnValue({ b: '0.8', tv_coef_reduction_deperdition_id: '101' }); + const di = {}; + const de = { + enum_type_adjacence_id: '8', + enum_cfg_isolation_lnc_id: '2', + surface_aiu: 10, + surface_aue: 20 + }; + + b(di, de, {}, 1); + + expect(tv).toHaveBeenCalledWith(TABLE, { + enum_type_adjacence_id: '8', + enum_cfg_isolation_lnc_id: '2', + aiu_aue_min: '0,25 <', + aiu_aue_max: '≤ 0,50' + }); + expect(di.b).toBe(0.8); + }); + + test('aucune valeur forfaitaire trouvée : di.b reste indéfini', () => { + tv.mockReturnValue(null); + const di = {}; + const de = { enum_type_adjacence_id: '10', enum_cfg_isolation_lnc_id: '7' }; + + b(di, de, {}, 1); + + expect(di.b).toBeUndefined(); + }); +}); From 51fdc97dbbfa18e94eede98f3486ac6db72ac751 Mon Sep 17 00:00:00 2001 From: Florent Perinel Date: Mon, 13 Jul 2026 11:41:51 +0200 Subject: [PATCH 02/20] test: add unit test for conso eclairage --- src/16_conso_eclairage.spec.js | 62 ++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 src/16_conso_eclairage.spec.js diff --git a/src/16_conso_eclairage.spec.js b/src/16_conso_eclairage.spec.js new file mode 100644 index 0000000..dc42447 --- /dev/null +++ b/src/16_conso_eclairage.spec.js @@ -0,0 +1,62 @@ +import { describe, expect, test, vi } from 'vitest'; + +/** + * Le module ne dépend que du mapping enum_zone_climatique_id -> libellé de zone. + * On le mocke afin d'isoler le test du contenu réel de `enums`. + */ +vi.mock('./enums.js', () => ({ + default: { + zone_climatique: { + 1: 'h1a', + 2: 'h1b', + 3: 'h1c', + 4: 'h2a', + 5: 'h2b', + 6: 'h2c', + 7: 'h2d', + 8: 'h3' + } + } +})); + +const { default: calc_conso_eclairage } = await import('./16_conso_eclairage.js'); + +/** + * 16.1 Consommation d'éclairage (Cecl) + * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §16.1 + * + * Cecl = (C * Pecl * Nh) / 1000 avec C = 0.9 (interrupteur) et Pecl = 1.4 W/m² + * Nh = nombre d'heures de fonctionnement de l'éclairage, fonction de la zone climatique. + */ +describe("Calcul de la consommation d'éclairage (Cecl)", () => { + // [enum_zone_climatique_id, libellé, Nh, Cecl attendu] + test.each([ + ['1', 'h1a', 1500, (0.9 * 1.4 * 1500) / 1000], + ['2', 'h1b', 1445, (0.9 * 1.4 * 1445) / 1000], + ['3', 'h1c', 1476, (0.9 * 1.4 * 1476) / 1000], + ['4', 'h2a', 1500, (0.9 * 1.4 * 1500) / 1000], + ['5', 'h2b', 1531, (0.9 * 1.4 * 1531) / 1000], + ['6', 'h2c', 1566, (0.9 * 1.4 * 1566) / 1000], + ['7', 'h2d', 1566, (0.9 * 1.4 * 1566) / 1000], + ['8', 'h3', 1506, (0.9 * 1.4 * 1506) / 1000] + ])( + 'zone climatique %s (%s) : Nh=%d heures => Cecl attendu', + (zc_id, _libelle, _nh, cecl_attendu) => { + expect(calc_conso_eclairage(zc_id)).toBeCloseTo(cecl_attendu, 10); + } + ); + + test('la consommation est identique pour deux zones ayant le même Nh (h1a et h2a)', () => { + expect(calc_conso_eclairage('1')).toBeCloseTo(calc_conso_eclairage('4'), 10); + }); + + test("la consommation croît avec le nombre d'heures (h1b < h3 < h2c)", () => { + expect(calc_conso_eclairage('2')).toBeLessThan(calc_conso_eclairage('8')); + expect(calc_conso_eclairage('8')).toBeLessThan(calc_conso_eclairage('6')); + }); + + test('retourne NaN pour un identifiant de zone climatique inconnu', () => { + // enums.zone_climatique[zc_id] est undefined => Nh[undefined] est undefined => NaN + expect(calc_conso_eclairage('99')).toBeNaN(); + }); +}); From faaea7be42f277bc0a7183f39d0b85342484a1c8 Mon Sep 17 00:00:00 2001 From: Florent Perinel Date: Mon, 13 Jul 2026 16:34:51 +0200 Subject: [PATCH 03/20] test: add unit test for conso ventilation --- src/5_conso_ventilation.spec.js | 71 +++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 src/5_conso_ventilation.spec.js diff --git a/src/5_conso_ventilation.spec.js b/src/5_conso_ventilation.spec.js new file mode 100644 index 0000000..008946c --- /dev/null +++ b/src/5_conso_ventilation.spec.js @@ -0,0 +1,71 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +/** + * `requestInput` est mocké en simple passe-plat : il retourne la valeur portée par les données + * d'entrée `de` pour le champ demandé. Cela isole `calc_pvent` de la logique de `utils`. + */ +vi.mock('./utils.js', () => ({ + requestInput: (de, du, field) => de[field] +})); + +const { default: calc_pvent } = await import('./5_conso_ventilation.js'); + +/** + * 5. Consommation des auxiliaires de ventilation + * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §5, pages 41-42 + */ +describe('calc_pvent - consommation des auxiliaires de ventilation', () => { + let di; + let du; + + beforeEach(() => { + di = {}; + du = {}; + }); + + test('ne calcule rien si le type de ventilation est indéfini', () => { + calc_pvent(di, {}, du, 'maison'); + expect(di).toEqual({}); + }); + + test('ventilation naturelle : consommation nulle', () => { + const de = { type_ventilation: 'ventilation naturelle par conduit', ventilation_post_2012: 0 }; + calc_pvent(di, de, du, 'maison'); + expect(di.conso_auxiliaire_ventilation).toBe(0); + }); + + test('maison, VMC simple flux auto après 2012 : Pvent = 35 W', () => { + const de = { type_ventilation: 'vmc sf auto réglable après 2012', ventilation_post_2012: 1 }; + calc_pvent(di, de, du, 'maison'); + // pvent_moy_maison['simple flux auto'][1] * coef(non hybride = 1) = 35 + expect(di.pvent_moy).toBe(35); + expect(di.conso_auxiliaire_ventilation).toBeCloseTo(8.76 * 35, 10); + }); + + test('maison, VMC double flux avant 2013 : Pvent = 80 W', () => { + const de = { type_ventilation: 'vmc df sans échangeur avant 2013', ventilation_post_2012: 0 }; + calc_pvent(di, de, du, 'maison'); + expect(di.pvent_moy).toBe(80); + }); + + test("maison, ventilation hybride : ratio de temps d'utilisation appliqué (14/168)", () => { + const de = { type_ventilation: 'ventilation hybride après 2012', ventilation_post_2012: 1 }; + calc_pvent(di, de, du, 'maison'); + // hybride => type 'simple flux auto', post_2012 forcé à 0, coef = 14 / (24*7) + const coef = 14 / (24 * 7); + expect(di.pvent_moy).toBeCloseTo(65 * coef, 10); + }); + + test('immeuble, VMC double flux : Pvent proratisé au débit et à la surface ventilée', () => { + di.qvarep_conv = 2; + const de = { + type_ventilation: 'vmc df collective avec échangeur avant 2013', + ventilation_post_2012: 0, + surface_ventile: 50 + }; + calc_pvent(di, de, du, 'immeuble'); + // pvent_immeuble['double flux'][0] * qvarep_conv * surface_ventile * coef(1) = 1.1 * 2 * 50 + expect(di.pvent_moy).toBeCloseTo(1.1 * 2 * 50, 10); + expect(di.conso_auxiliaire_ventilation).toBeCloseTo(8.76 * 110, 10); + }); +}); From 81e7adbe0aeb52278f122669ec97a76c69cc54f0 Mon Sep 17 00:00:00 2001 From: Florent Perinel Date: Mon, 13 Jul 2026 16:35:00 +0200 Subject: [PATCH 04/20] test: add unit test for surface sud --- src/6.2_surface_sud_equivalente.spec.js | 108 ++++++++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 src/6.2_surface_sud_equivalente.spec.js diff --git a/src/6.2_surface_sud_equivalente.spec.js b/src/6.2_surface_sud_equivalente.spec.js new file mode 100644 index 0000000..76fe9d9 --- /dev/null +++ b/src/6.2_surface_sud_equivalente.spec.js @@ -0,0 +1,108 @@ +import { describe, expect, test, vi } from 'vitest'; + +/** + * Dépendances mockées : + * - `enums` : orientation et inclinaison des vitrages (détermine la clé de la table c1) ; + * - `tvs.c1` : coefficient d'ensoleillement mensuel par zone/orientation (valeurs contrôlées) ; + * - `mois_liste` : réduite à un mois pour rendre l'agrégation annuelle vérifiable. + */ +vi.mock('./enums.js', () => ({ + default: { + orientation: { 1: 'sud', 2: 'horizontal' }, + inclinaison_vitrage: { 3: 'verticale', 4: 'horizontal' } + } +})); + +vi.mock('./tv.js', () => ({ + default: { + c1: { + h1a: { + Janvier: { 'sud verticale': 0.5, horizontal: 0.3 } + } + } + } +})); + +vi.mock('./utils.js', () => ({ + mois_liste: ['Janvier'] +})); + +const { calc_sse_j, calc_sse } = await import('./6.2_surface_sud_equivalente.js'); + +/** Fabrique une baie vitrée avec les données d'entrée/intermédiaires utiles au calcul. */ +function baie({ adjacence, surface, orientationId = '1', inclinaisonId = '3', sw = 1 }) { + return { + donnee_entree: { + enum_type_adjacence_id: adjacence, + enum_orientation_id: orientationId, + enum_inclinaison_vitrage_id: inclinaisonId, + surface_totale_baie: surface + }, + donnee_intermediaire: { sw } + }; +} + +/** + * 6.2 Calcul de la surface sud équivalente (SSE) + * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §6.2 / §6.3 + */ +describe('calc_sse_j - surface sud équivalente journalière', () => { + test("sans espace tampon : somme sur les baies donnant sur l'extérieur", () => { + // getSsd = surface * c1j * sw = 2 * 0.5 * 0.4 + const bvList = [baie({ adjacence: '1', surface: 2, sw: 0.4 })]; + expect(calc_sse_j(bvList, null, 'ca1', 'h1a', 'Janvier')).toBeCloseTo(0.4, 10); + }); + + test("les baies non extérieures sont ignorées en l'absence d'espace tampon", () => { + const bvList = [ + baie({ adjacence: '1', surface: 2, sw: 0.4 }), + baie({ adjacence: '3', surface: 5, sw: 0.9 }) // adjacence autre => ignorée + ]; + expect(calc_sse_j(bvList, null, 'ca1', 'h1a', 'Janvier')).toBeCloseTo(0.4, 10); + }); + + test('une baie horizontale utilise la clé "horizontal" de la table c1', () => { + const bvList = [baie({ adjacence: '1', surface: 2, orientationId: '2', sw: 0.4 })]; + // c1j = 0.3 => 2 * 0.3 * 0.4 + expect(calc_sse_j(bvList, null, 'ca1', 'h1a', 'Janvier')).toBeCloseTo(0.24, 10); + }); + + test('avec espace tampon solarisé (véranda) : apports directs + indirects', () => { + const bvList = [ + baie({ adjacence: '10', surface: 3, sw: 0.5 }), // baie logement / véranda + baie({ adjacence: '1', surface: 2, sw: 0.4 }) // baie extérieure + ]; + const ets = { + donnee_intermediaire: { bver: 0.5, coef_transparence_ets: 0.8 }, + baie_ets_collection: { + baie_ets: baie({ adjacence: '1', surface: 4, sw: 1 }) + } + }; + + // Ssdj = T * (3*0.5*0.5) = 0.8 * 0.75 = 0.6 + // Sstj = 4 * 0.5 * (0.8*0.8 + 0.024) = 2 * 0.664 = 1.328 + // ssIndj = 1.328 - 0.6 = 0.728 ; SseVeranda = 0.6 + 0.728*0.5 = 0.964 + // sseBaiesExt = 2*0.5*0.4 = 0.4 ; total = 1.364 + expect(calc_sse_j(bvList, ets, 'ca1', 'h1a', 'Janvier')).toBeCloseTo(1.364, 10); + }); + + test('un espace tampon fourni sous forme de tableau utilise le premier élément', () => { + const bvList = [baie({ adjacence: '10', surface: 3, sw: 0.5 })]; + const ets = [ + { + donnee_intermediaire: { bver: 0.5, coef_transparence_ets: 0.8 }, + baie_ets_collection: { baie_ets: baie({ adjacence: '1', surface: 4, sw: 1 }) } + } + ]; + // pas de baie extérieure : total = SseVeranda = 0.964 + expect(calc_sse_j(bvList, ets, 'ca1', 'h1a', 'Janvier')).toBeCloseTo(0.964, 10); + }); +}); + +describe('calc_sse - surface sud équivalente annuelle', () => { + test('agrège la surface sud équivalente sur tous les mois', () => { + const bvList = [baie({ adjacence: '1', surface: 2, sw: 0.4 })]; + // un seul mois mocké => identique au calcul journalier + expect(calc_sse('ca1', 'h1a', bvList, null)).toBeCloseTo(0.4, 10); + }); +}); From b09e472d26d289743b7da6e0f4346c41f3809b0b Mon Sep 17 00:00:00 2001 From: Florent Perinel Date: Mon, 13 Jul 2026 16:35:16 +0200 Subject: [PATCH 05/20] test: add unit test for intermitence --- src/8_intermittence.spec.js | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 src/8_intermittence.spec.js diff --git a/src/8_intermittence.spec.js b/src/8_intermittence.spec.js new file mode 100644 index 0000000..e77291c --- /dev/null +++ b/src/8_intermittence.spec.js @@ -0,0 +1,33 @@ +import { calc_intermittence } from './8_intermittence.js'; +import { describe, expect, test } from 'vitest'; + +/** + * 8. Modélisation de l'intermittence + * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §8 + * + * i = i0 / (1 + 0.1 * (G - 1)) avec G = GV / (Sh * Hsp) + * (fonction pure, aucune dépendance à mocker). + */ +describe("Calcul de l'intermittence (int)", () => { + test("G > 1 : le facteur d'intermittence est réduit sous i0", () => { + // GV=300, Sh=100, Hsp=2.5 => G = 1.2 => 0.9 / (1 + 0.1 * 0.2) + expect(calc_intermittence(300, 100, 2.5, 0.9)).toBeCloseTo(0.8823529411764706, 12); + }); + + test("G = 1 : le facteur d'intermittence vaut exactement i0", () => { + // GV = Sh * Hsp => G = 1 => i0 / (1 + 0) = i0 + expect(calc_intermittence(250, 100, 2.5, 0.85)).toBe(0.85); + }); + + test("le facteur d'intermittence décroît quand G (donc GV) augmente", () => { + const faibleG = calc_intermittence(250, 100, 2.5, 0.9); + const fortG = calc_intermittence(500, 100, 2.5, 0.9); + expect(fortG).toBeLessThan(faibleG); + }); + + test('le résultat est proportionnel à i0 pour un même G', () => { + const a = calc_intermittence(300, 100, 2.5, 0.9); + const b = calc_intermittence(300, 100, 2.5, 0.45); + expect(a).toBeCloseTo(2 * b, 12); + }); +}); From 1b6609f9fc9a7172c4b1c87e255f4b33cf0379fa Mon Sep 17 00:00:00 2001 From: Florent Perinel Date: Mon, 13 Jul 2026 16:35:23 +0200 Subject: [PATCH 06/20] test: add unit test for besoin froid --- src/10_besoin_fr.spec.js | 113 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 src/10_besoin_fr.spec.js diff --git a/src/10_besoin_fr.spec.js b/src/10_besoin_fr.spec.js new file mode 100644 index 0000000..ae051b4 --- /dev/null +++ b/src/10_besoin_fr.spec.js @@ -0,0 +1,113 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +/** + * Dépendances mockées pour isoler le module : + * - tables de valeurs (`tvs`) : profils mensuels nref / e_fr / textmoy_clim ; + * - calcul des apports internes/solaires (`calc_ai_j`, `calc_as_j`) et surface sud équivalente + * (`calc_sse_j`) : simples fonctions dont on contrôle le retour ; + * - `mois_liste` : réduite à un seul mois pour rendre la somme vérifiable. + */ +vi.mock('./tv.js', () => ({ + tvs: { + nref26: { ca1: { Janvier: { h1a: 10 } } }, + nref28: { ca1: { Janvier: { h1a: 10 } } }, + e_fr_26: { ca1: { Janvier: { h1a: 100 } } }, + e_fr_28: { ca1: { Janvier: { h1a: 100 } } }, + textmoy_clim_26: { ca1: { Janvier: { h1a: 30 } } }, + textmoy_clim_28: { ca1: { Janvier: { h1a: 30 } } } + } +})); + +vi.mock('./utils.js', () => ({ + mois_liste: ['Janvier'] +})); + +vi.mock('./6.1_apport_gratuit.js', () => ({ + calc_ai_j: vi.fn(), + calc_as_j: vi.fn() +})); + +vi.mock('./6.2_surface_sud_equivalente.js', () => ({ + calc_sse_j: vi.fn() +})); + +const { default: calc_besoin_fr, calc_besoin_fr_j } = await import('./10_besoin_fr.js'); +const { calc_ai_j, calc_as_j } = await import('./6.1_apport_gratuit.js'); +const { calc_sse_j } = await import('./6.2_surface_sud_equivalente.js'); + +/** + * 10. Calcul des besoins de froid (Bfr) + * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §10 + */ +describe('calc_besoin_fr_j - besoin de froid journalier', () => { + test('retourne 0 lorsque le nombre de jours de référence est nul', () => { + expect(calc_besoin_fr_j(100, 300, 'moyenne', 5000, 2000, 0, 30, 28)).toBe(0); + }); + + test('retourne 0 lorsque le taux Rbth est inférieur à 1/2 (apports insuffisants)', () => { + // aij + asj = 0 => Rbth = 0 < 0.5 + expect(calc_besoin_fr_j(100, 300, 'moyenne', 0, 0, 10, 30, 28)).toBe(0); + }); + + test('valeur de référence (Tint=28°C, inertie moyenne)', () => { + expect(calc_besoin_fr_j(100, 300, 'moyenne', 5000, 2000, 10, 30, 28)).toBeCloseTo( + 11.312243423584366, + 9 + ); + }); + + test('valeur de référence en mode dépensier (Tint=26°C)', () => { + expect(calc_besoin_fr_j(100, 300, 'moyenne', 5000, 2000, 10, 30, 26)).toBeCloseTo( + 12.777039072730146, + 9 + ); + }); + + test('le besoin de froid dépensier (26°C) est supérieur au besoin conventionnel (28°C)', () => { + const conventionnel = calc_besoin_fr_j(100, 300, 'moyenne', 5000, 2000, 10, 30, 28); + const depensier = calc_besoin_fr_j(100, 300, 'moyenne', 5000, 2000, 10, 30, 26); + expect(depensier).toBeGreaterThan(conventionnel); + }); +}); + +describe('calc_besoin_fr - agrégation mensuelle', () => { + beforeEach(() => { + vi.mocked(calc_ai_j).mockReset(); + vi.mocked(calc_as_j).mockReset(); + vi.mocked(calc_sse_j).mockReset(); + }); + + test('agrège le besoin conventionnel et dépensier sur les mois', () => { + vi.mocked(calc_ai_j).mockReturnValue(5000); + vi.mocked(calc_as_j).mockReturnValue(2000); + vi.mocked(calc_sse_j).mockReturnValue(10); + + const ret = calc_besoin_fr('ca1', 'h1a', 100, 3, 300, 'moyenne', [], null); + + expect(ret.besoin_fr).toBeCloseTo(11.312243423584366, 9); + expect(ret.besoin_fr_depensier).toBeCloseTo(12.777039072730146, 9); + }); + + test('délègue le calcul de la surface sud équivalente mois par mois', () => { + vi.mocked(calc_ai_j).mockReturnValue(0); + vi.mocked(calc_as_j).mockReturnValue(0); + vi.mocked(calc_sse_j).mockReturnValue(0); + + calc_besoin_fr('ca1', 'h1a', 100, 3, 300, 'moyenne', ['bv'], 'ets'); + + expect(calc_sse_j).toHaveBeenCalledWith(['bv'], 'ets', 'ca1', 'h1a', 'Janvier'); + // apports internes calculés à partir de la surface et du nombre d'adultes équivalents + expect(calc_ai_j).toHaveBeenCalledWith(100, 3, 10); + }); + + test('un besoin nul sur tous les mois donne un besoin annuel nul', () => { + vi.mocked(calc_ai_j).mockReturnValue(0); + vi.mocked(calc_as_j).mockReturnValue(0); + vi.mocked(calc_sse_j).mockReturnValue(0); + + const ret = calc_besoin_fr('ca1', 'h1a', 100, 3, 300, 'moyenne', [], null); + + expect(ret.besoin_fr).toBe(0); + expect(ret.besoin_fr_depensier).toBe(0); + }); +}); From 932d565f14cc72399dbc2a04e04b30b318237988 Mon Sep 17 00:00:00 2001 From: Florent Perinel Date: Mon, 13 Jul 2026 16:35:32 +0200 Subject: [PATCH 07/20] test: add unit test for consommation auxiliaire --- src/15_conso_aux.spec.js | 121 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 src/15_conso_aux.spec.js diff --git a/src/15_conso_aux.spec.js b/src/15_conso_aux.spec.js new file mode 100644 index 0000000..9c60322 --- /dev/null +++ b/src/15_conso_aux.spec.js @@ -0,0 +1,121 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +/** + * Dépendances mockées uniquement pour `conso_aux_distribution_ch` : + * - `enums` : libellés de classe d'altitude / zone climatique ; + * - `tvs.nref19` : nombre d'heures de fonctionnement mensuel ; + * - `mois_liste` / `Tbase` : réduits à des valeurs contrôlées. + * `conso_aux_gen` n'utilise aucune de ces dépendances (calcul autonome). + */ +vi.mock('./enums.js', () => ({ + default: { + classe_altitude: { 1: 'ca1' }, + zone_climatique: { 1: 'h1a' } + } +})); + +vi.mock('./tv.js', () => ({ + default: { + nref19: { 0: { ca1: { Janvier: { h1a: 100 } } } } + } +})); + +vi.mock('./utils.js', () => ({ + mois_liste: ['Janvier'], + Tbase: { ca1: { h1: -9 } } +})); + +const { conso_aux_gen, conso_aux_distribution_ch } = await import('./15_conso_aux.js'); + +/** + * 15.1 Consommation des auxiliaires de génération + * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §15.1 + */ +describe('conso_aux_gen - auxiliaires de génération', () => { + let di; + + beforeEach(() => { + di = { pn: 20000 }; + }); + + test('chaudière gaz (chauffage) : facteurs G=20, H=1.6 appliqués', () => { + const de = { enum_type_generateur_ch_id: '90' }; + conso_aux_gen(di, de, 'ch', 1000, 1200, 100); + expect(di.conso_auxiliaire_generation_ch).toBeCloseTo(2.6, 10); + expect(di.conso_auxiliaire_generation_ch_depensier).toBeCloseTo(3.12, 10); + }); + + test('générateur hors des plages connues : consommation nulle (G=H=0)', () => { + const de = { enum_type_generateur_ch_id: '1' }; + conso_aux_gen(di, de, 'ch', 1000, 1200, 100); + expect(di.conso_auxiliaire_generation_ch).toBe(0); + expect(di.conso_auxiliaire_generation_ch_depensier).toBe(0); + }); + + test("chaudière bois : facteurs appliqués seulement en présence d'un ventilateur", () => { + const avecVentilateur = { pn: 20000 }; + conso_aux_gen( + avecVentilateur, + { enum_type_generateur_ch_id: '60', presenceVentilateur: 1 }, + 'ch', + 1000, + 1200, + 100 + ); + expect(avecVentilateur.conso_auxiliaire_generation_ch).toBeCloseTo(14.165, 10); + + const sansVentilateur = { pn: 20000 }; + conso_aux_gen( + sansVentilateur, + { enum_type_generateur_ch_id: '60', presenceVentilateur: 0 }, + 'ch', + 1000, + 1200, + 100 + ); + expect(sansVentilateur.conso_auxiliaire_generation_ch).toBe(0); + }); + + test('chaudière gaz avec Pn > 400 kW : puissance plafonnée à 400 kW', () => { + const diCap = { pn: 500000 }; + conso_aux_gen(diCap, { enum_type_generateur_ch_id: '90' }, 'ch', 1000, 1200, 100); + expect(diCap.conso_auxiliaire_generation_ch).toBeCloseTo(1.65, 10); + expect(diCap.conso_auxiliaire_generation_ch_depensier).toBeCloseTo(1.584, 10); + }); + + test('type ECS : aucun prorata de surface chauffée appliqué', () => { + const de = { enum_type_generateur_ecs_id: '50' }; + conso_aux_gen(di, de, 'ecs', 800, 1000, 100); + expect(di.conso_auxiliaire_generation_ecs).toBeCloseTo(2.08, 10); + expect(di.conso_auxiliaire_generation_ecs_depensier).toBeCloseTo(2.6, 10); + }); +}); + +/** + * 15.2 Consommation des auxiliaires de distribution de chauffage + * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §15.2 + */ +describe('conso_aux_distribution_ch - auxiliaires de distribution', () => { + const emCh = [ + { + donnee_entree: { + enum_type_emission_distribution_id: '10', + enum_temp_distribution_ch_id: '3' + } + } + ]; + + test('puissance de circulateur plafonnée à 30 W pour une faible déperdition', () => { + const di = {}; + // GV très faible => la puissance calculée reste sous le plancher de 30 W + conso_aux_distribution_ch(emCh, {}, di, {}, 100, 1, 1, '0', 1); + // Pcircem19 = 30 (plancher), nref19 = 100 => 30 * 100 / 1000 = 3 + expect(di.conso_auxiliaire_distribution_ch).toBeCloseTo(3, 10); + }); + + test('une déperdition élevée augmente la consommation au-dessus du plancher', () => { + const di = {}; + conso_aux_distribution_ch(emCh, {}, di, {}, 100, 1, 1, '0', 100000); + expect(di.conso_auxiliaire_distribution_ch).toBeGreaterThan(3); + }); +}); From 150efd17e3b9629ee037d342199dcb22bdf52096 Mon Sep 17 00:00:00 2001 From: Florent Perinel Date: Mon, 13 Jul 2026 17:00:04 +0200 Subject: [PATCH 08/20] test: add unit test for ventilation --- src/4_ventilation.js | 2 +- src/4_ventilation.spec.js | 139 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 src/4_ventilation.spec.js diff --git a/src/4_ventilation.js b/src/4_ventilation.js index 263ea59..e8e9be0 100644 --- a/src/4_ventilation.js +++ b/src/4_ventilation.js @@ -120,7 +120,7 @@ const f_tab = { 0: 20 }; -function calc_hperm(di, surface_ventile, Hsp, Sdep, pfe) { +export function calc_hperm(di, surface_ventile, Hsp, Sdep, pfe) { const e = e_tab[pfe]; const f = f_tab[pfe]; const Q4pa_env = di.q4pa_conv * Sdep; diff --git a/src/4_ventilation.spec.js b/src/4_ventilation.spec.js new file mode 100644 index 0000000..046ee56 --- /dev/null +++ b/src/4_ventilation.spec.js @@ -0,0 +1,139 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +/** + * Dépendances mockées pour isoler le module de ventilation : + * - `tv` : accès aux tables `debits_ventilation` / `q4pa_conv`, on contrôle les lignes ; + * - `requestInput` / `requestInputID` : passe-plats vers les données d'entrée ; + * - `bug_for_bug_compat` : désactivé pour isoler le comportement nominal ; + * - `calc_pvent` (5_conso_ventilation.js) : espion, la conso de ventilation est testée ailleurs. + */ +vi.mock('./utils.js', () => ({ + tv: vi.fn(), + requestInput: (de, du, field) => de[field], + requestInputID: (de, du, field) => de[`enum_${field}_id`], + bug_for_bug_compat: false +})); + +vi.mock('./5_conso_ventilation.js', () => ({ + default: vi.fn() +})); + +const { default: calc_ventilation, calc_hperm } = await import('./4_ventilation.js'); +const { tv } = await import('./utils.js'); +const { default: calc_pvent } = await import('./5_conso_ventilation.js'); + +/** Ligne figée de la table `debits_ventilation`. */ +const ROW_DEBITS = { + qvarep_conv: '1.2', + qvasouf_conv: '0.5', + smea_conv: '20', + tv_debits_ventilation_id: '3' +}; + +beforeEach(() => { + tv.mockReset(); + vi.mocked(calc_pvent).mockReset(); + tv.mockImplementation((table) => { + if (table === 'debits_ventilation') return { ...ROW_DEBITS }; + if (table === 'q4pa_conv') return { q4pa_conv: '1.3', tv_q4pa_conv_id: '9' }; + return null; + }); +}); + +/** + * Déperdition par renouvellement d'air dû aux infiltrations (Hperm) + * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §4 + */ +describe('calc_hperm - déperdition par perméabilité', () => { + test('plusieurs façades exposées (pfe=1) : e=0,07 et f=15', () => { + const di = { q4pa_conv: 1.3, smea_conv: 20, qvasouf_conv: 0.5, qvarep_conv: 1.2 }; + calc_hperm(di, 100, 2.5, 80, 1); + // valeur de référence de régression + expect(di.hperm).toBeCloseTo(124.24055432137072, 9); + }); + + test('une seule façade exposée (pfe=0) : e=0,02 et f=20', () => { + const di = { q4pa_conv: 1.3, smea_conv: 20, qvasouf_conv: 0.5, qvarep_conv: 1.2 }; + calc_hperm(di, 100, 2.5, 80, 0); + // valeur de référence de régression + expect(di.hperm).toBeCloseTo(31.494527138357977, 9); + }); + + test('le nombre de façades exposées modifie le résultat', () => { + const diMulti = { q4pa_conv: 1.3, smea_conv: 20, qvasouf_conv: 0.5, qvarep_conv: 1.2 }; + const diSingle = { q4pa_conv: 1.3, smea_conv: 20, qvasouf_conv: 0.5, qvarep_conv: 1.2 }; + calc_hperm(diMulti, 100, 2.5, 80, 1); + calc_hperm(diSingle, 100, 2.5, 80, 0); + expect(diMulti.hperm).not.toBeCloseTo(diSingle.hperm, 5); + }); +}); + +/** Fabrique un objet ventilation avec les données d'entrée utiles. */ +function ventilation(de = {}) { + return { donnee_entree: { enum_type_ventilation_id: '1', ...de } }; +} + +const CG = { + hsp: 2.5, + enum_periode_construction_id: '1', + enum_methode_application_dpe_log_id: '1' +}; + +describe('calc_ventilation - orchestration', () => { + test('surface ventilée absente : la surface habitable Sh est utilisée', () => { + const vt = ventilation({ q4pa_conv_saisi: 1.3 }); + calc_ventilation(vt, CG, 'th', 80, 100, [], [], [], []); + + // Hvent = 0,34 * qvarep_conv * surface_ventile = 0,34 * 1,2 * 100 + expect(vt.donnee_intermediaire.hvent).toBeCloseTo(40.8, 9); + }); + + test('surface ventilée collective : divisée par la clé de répartition', () => { + const vt = ventilation({ + surface_ventile: 200, + cle_repartition_ventilation: 2, + q4pa_conv_saisi: 1.3 + }); + calc_ventilation(vt, CG, 'th', 80, 100, [], [], [], []); + + // surface_ventile = 200 / 2 = 100 => Hvent = 0,34 * 1,2 * 100 + expect(vt.donnee_intermediaire.hvent).toBeCloseTo(40.8, 9); + }); + + test('Q4Pa mesuré saisi : utilisé directement, sans lecture de la table q4pa_conv', () => { + const vt = ventilation({ q4pa_conv_saisi: 0.6 }); + calc_ventilation(vt, CG, 'th', 80, 100, [], [], [], []); + + expect(vt.donnee_intermediaire.q4pa_conv).toBe(0.6); + expect(tv).not.toHaveBeenCalledWith('q4pa_conv', expect.anything()); + }); + + test('Q4Pa non saisi : lu dans la table q4pa_conv', () => { + const vt = ventilation(); + calc_ventilation(vt, CG, 'th', 80, 100, [], [], [], []); + + expect(tv).toHaveBeenCalledWith('q4pa_conv', expect.any(Object)); + expect(vt.donnee_intermediaire.q4pa_conv).toBe(1.3); + }); + + test('les débits conventionnels intermédiaires sont supprimés du résultat', () => { + const vt = ventilation({ q4pa_conv_saisi: 1.3 }); + calc_ventilation(vt, CG, 'th', 80, 100, [], [], [], []); + + expect(vt.donnee_intermediaire.qvarep_conv).toBeUndefined(); + expect(vt.donnee_intermediaire.qvasouf_conv).toBeUndefined(); + expect(vt.donnee_intermediaire.smea_conv).toBeUndefined(); + }); + + test('la consommation de ventilation est déléguée à calc_pvent', () => { + const vt = ventilation({ q4pa_conv_saisi: 1.3 }); + calc_ventilation(vt, CG, 'th', 80, 100, [], [], [], []); + + expect(calc_pvent).toHaveBeenCalledWith( + vt.donnee_intermediaire, + vt.donnee_entree, + vt.donnee_utilisateur, + 'th' + ); + }); +}); From 99c57015843e9033ce72ee9b7a278ae0d788c8ab Mon Sep 17 00:00:00 2001 From: Florent Perinel Date: Mon, 13 Jul 2026 17:00:16 +0200 Subject: [PATCH 09/20] test: add unit test for besoin chauffage --- src/9_besoin_ch.js | 4 +- src/9_besoin_ch.spec.js | 225 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 227 insertions(+), 2 deletions(-) create mode 100644 src/9_besoin_ch.spec.js diff --git a/src/9_besoin_ch.js b/src/9_besoin_ch.js index 368199f..10e7088 100644 --- a/src/9_besoin_ch.js +++ b/src/9_besoin_ch.js @@ -239,7 +239,7 @@ function calc_qrec(instal_ecs, nadeq, prorataEcs, ilpa, ca, zc, th, nbLogements) return { Qrec, Qrec_dep, sumNref19, sumNref21 }; } -function calc_Fj(GV, asj, aij, dhj, inertie) { +export function calc_Fj(GV, asj, aij, dhj, inertie) { if (dhj === 0) return 0; let alpha; @@ -251,6 +251,6 @@ function calc_Fj(GV, asj, aij, dhj, inertie) { return (Xj - Xj ** alpha) / (1 - Xj ** alpha); } -function calc_bvj(GV, Fj) { +export function calc_bvj(GV, Fj) { return GV * (1 - Fj); } diff --git a/src/9_besoin_ch.spec.js b/src/9_besoin_ch.spec.js new file mode 100644 index 0000000..e54ccb3 --- /dev/null +++ b/src/9_besoin_ch.spec.js @@ -0,0 +1,225 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +/** + * Dépendances mockées pour isoler `9_besoin_ch.js` : + * - `enums` : mapping altitude / zone climatique / inertie ; + * - `tvs` : profils mensuels dh19/dh21/nref19/nref21/e (valeurs contrôlées) ; + * - apports internes/solaires (`calc_ai_j`, `calc_as_j`) et surface sud + * équivalente (`calc_sse_j`) : simples fonctions dont on fige le retour ; + * - besoin ECS journalier (`calc_besoin_ecs_j`) et récupération générateur + * (`calc_Qrec_gen_j`) : mockés pour piloter les branches de récupération ; + * - `mois_liste` réduite à un mois et `bug_for_bug_compat` désactivé. + */ +vi.mock('./enums.js', () => ({ + default: { + classe_altitude: { 0: 'ca1' }, + zone_climatique: { 0: 'h1a' }, + classe_inertie: { 0: 'moyenne' } + } +})); + +vi.mock('./tv.js', () => ({ + default: { + dh19: { 0: { ca1: { Janvier: { h1a: 1000 } } } }, + dh21: { 0: { ca1: { Janvier: { h1a: 1200 } } } }, + nref19: { 0: { ca1: { Janvier: { h1a: 100 } } } }, + nref21: { 0: { ca1: { Janvier: { h1a: 120 } } } }, + e: { 0: { ca1: { Janvier: { h1a: 500 } } } } + } +})); + +vi.mock('./6.1_apport_gratuit.js', () => ({ + calc_ai_j: vi.fn(), + calc_as_j: vi.fn() +})); + +vi.mock('./6.2_surface_sud_equivalente.js', () => ({ + calc_sse_j: vi.fn() +})); + +vi.mock('./11_besoin_ecs.js', () => ({ + calc_besoin_ecs_j: vi.fn() +})); + +vi.mock('./9_generateur_ch.js', () => ({ + calc_Qrec_gen_j: vi.fn() +})); + +vi.mock('./utils.js', () => ({ + mois_liste: ['Janvier'], + bug_for_bug_compat: false +})); + +const { default: calc_besoin_ch, calc_Fj, calc_bvj } = await import('./9_besoin_ch.js'); +const { calc_ai_j, calc_as_j } = await import('./6.1_apport_gratuit.js'); +const { calc_sse_j } = await import('./6.2_surface_sud_equivalente.js'); +const { calc_besoin_ecs_j } = await import('./11_besoin_ecs.js'); +const { calc_Qrec_gen_j } = await import('./9_generateur_ch.js'); + +/** + * 9. Besoins de chauffage (Bch) + * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §9 + */ +describe('calc_Fj - fraction des besoins de chauffage couverte par les apports', () => { + test('retourne 0 lorsque le nombre de degrés-heures est nul', () => { + expect(calc_Fj(100, 2000, 5000, 0, 'moyenne')).toBe(0); + }); + + test('valeur de référence de régression (inertie moyenne, alpha = 2.9)', () => { + expect(calc_Fj(100, 2000, 5000, 1000, 'moyenne')).toBeCloseTo(0.06958364704535852, 9); + }); + + test('une inertie lourde (alpha = 3.6) couvre une part différente de la légère (alpha = 2.5)', () => { + const lourde = calc_Fj(100, 2000, 5000, 1000, 'lourde'); + const legere = calc_Fj(100, 2000, 5000, 1000, 'légère'); + // valeurs de référence de régression + expect(lourde).toBeCloseTo(0.06993530568739618, 9); + expect(legere).toBeCloseTo(0.06879276604754976, 9); + expect(lourde).not.toBeCloseTo(legere, 9); + }); + + test('les inerties "très lourde" et "lourde" partagent le même alpha', () => { + expect(calc_Fj(100, 2000, 5000, 1000, 'très lourde')).toBeCloseTo( + calc_Fj(100, 2000, 5000, 1000, 'lourde'), + 12 + ); + }); +}); + +describe('calc_bvj - déperditions corrigées de la fraction récupérée', () => { + test('bvj = GV * (1 - Fj)', () => { + expect(calc_bvj(100, 0.3)).toBe(70); + expect(calc_bvj(100, 0)).toBe(100); + }); +}); + +describe('calc_besoin_ch - agrégation mensuelle du besoin de chauffage', () => { + beforeEach(() => { + vi.mocked(calc_ai_j).mockReset(); + vi.mocked(calc_as_j).mockReset(); + vi.mocked(calc_sse_j).mockReset(); + vi.mocked(calc_besoin_ecs_j).mockReset(); + vi.mocked(calc_Qrec_gen_j).mockReset(); + vi.mocked(calc_ai_j).mockReturnValue(5000); + vi.mocked(calc_as_j).mockReturnValue(2000); + vi.mocked(calc_sse_j).mockReturnValue(10); + vi.mocked(calc_besoin_ecs_j).mockReturnValue(10); + vi.mocked(calc_Qrec_gen_j).mockReturnValue(0); + }); + + /** Appel nominal sans installation (pas de récupération d'énergie). */ + function appelSansRecup() { + return calc_besoin_ch(0, 0, 0, 0, 100, 100, 3, [], [], [], null, 'maison', 1); + } + + test('valeurs de référence de régression sans récupération (ECS/générateur)', () => { + const ret = appelSansRecup(); + expect(ret.besoin_ch).toBeCloseTo(93.04163529546415, 9); + expect(ret.besoin_ch_depensier).toBeCloseTo(113.0298092944794, 9); + }); + + test('la fraction des apports gratuits est ramenée à la moyenne pondérée par les degrés-heures', () => { + const ret = appelSansRecup(); + // sur un seul mois : fraction = Fj + expect(ret.fraction_apport_gratuit_ch).toBeCloseTo(0.06958364704535852, 9); + expect(ret.fraction_apport_gratuit_depensier_ch).toBeCloseTo(0.05808492254600494, 9); + }); + + test('sans récupération, toutes les pertes récupérées retournées sont nulles', () => { + const ret = appelSansRecup(); + expect(ret.pertes_distribution_ecs_recup).toBe(0); + expect(ret.pertes_stockage_ecs_recup).toBe(0); + expect(ret.pertes_generateur_ch_recup).toBe(0); + expect(ret.pertes_generateur_ch_recup_depensier).toBe(0); + }); + + test('le besoin mensuel est exposé (en Wh) pour chaque mois calculé', () => { + const ret = appelSansRecup(); + expect(ret.besoin_ch_mois.Janvier).toBeCloseTo(93041.63529546415, 6); + }); + + /** + * 9.1.1 - Un générateur en volume chauffé avec pertes à l'arrêt (qp0) donne + * lieu à une récupération d'énergie qui diminue le besoin de chauffage. + */ + test('la récupération générateur diminue le besoin et appelle calc_Qrec_gen_j', () => { + vi.mocked(calc_Qrec_gen_j).mockReturnValue(1000); + const instal_ch = [ + { + generateur_chauffage_collection: { + generateur_chauffage: [ + { + donnee_intermediaire: { qp0: 1 }, + donnee_entree: { position_volume_chauffe: 1 } + } + ] + } + } + ]; + const ret = calc_besoin_ch(0, 0, 0, 0, 100, 100, 3, [], instal_ch, [], null, 'maison', 1); + + expect(calc_Qrec_gen_j).toHaveBeenCalled(); + expect(ret.pertes_generateur_ch_recup).toBeCloseTo(1000, 6); + // 93041.635 Wh de besoin - 1000 Wh récupérés = 92041.635 Wh + expect(ret.besoin_ch).toBeCloseTo(92.04163529546415, 6); + }); + + test("un générateur sans pertes à l'arrêt (qp0) n'est pas retenu pour la récupération", () => { + vi.mocked(calc_Qrec_gen_j).mockReturnValue(1000); + const instal_ch = [ + { + generateur_chauffage_collection: { + generateur_chauffage: [ + { + donnee_intermediaire: { qp0: 0 }, + donnee_entree: { position_volume_chauffe: 1 } + } + ] + } + } + ]; + const ret = calc_besoin_ch(0, 0, 0, 0, 100, 100, 3, [], instal_ch, [], null, 'maison', 1); + expect(calc_Qrec_gen_j).not.toHaveBeenCalled(); + expect(ret.pertes_generateur_ch_recup).toBe(0); + }); + + test('le besoin mensuel de chauffage est borné à zéro (Math.max)', () => { + // récupération générateur démesurée => besoin mensuel négatif ramené à 0 + vi.mocked(calc_Qrec_gen_j).mockReturnValue(1e9); + const instal_ch = [ + { + generateur_chauffage_collection: { + generateur_chauffage: [ + { + donnee_intermediaire: { qp0: 1 }, + donnee_entree: { position_volume_chauffe: 1 } + } + ] + } + } + ]; + const ret = calc_besoin_ch(0, 0, 0, 0, 100, 100, 3, [], instal_ch, [], null, 'maison', 1); + expect(ret.besoin_ch).toBe(0); + expect(ret.besoin_ch_mois.Janvier).toBe(0); + }); + + /** + * 11.4 - En présence d'une installation ECS, la récupération de distribution + * ECS (Qrec) diminue le besoin de chauffage et le besoin ECS journalier est + * sollicité pour chaque mois. + */ + test('une installation ECS collective alimente la récupération de distribution', () => { + const instal_ecs = [ + { + donnee_entree: { enum_type_installation_id: '2', rdim: 1 }, + generateur_ecs_collection: { generateur_ecs: [] } + } + ]; + const ret = calc_besoin_ch(0, 0, 0, 0, 100, 100, 3, instal_ecs, [], [], null, 'maison', 1); + + expect(calc_besoin_ecs_j).toHaveBeenCalled(); + expect(ret.pertes_distribution_ecs_recup).toBeGreaterThan(0); + // la récupération ECS réduit le besoin sous la valeur sans récupération + expect(ret.besoin_ch).toBeLessThan(93.04163529546415); + }); +}); From 05441fb82bde2447765d52d6079294bcec83f986 Mon Sep 17 00:00:00 2001 From: Florent Perinel Date: Mon, 13 Jul 2026 17:00:36 +0200 Subject: [PATCH 10/20] test: add unit test for generateur ecs --- src/14_generateur_ecs.js | 8 +- src/14_generateur_ecs.spec.js | 235 ++++++++++++++++++++++++++++++++++ 2 files changed, 239 insertions(+), 4 deletions(-) create mode 100644 src/14_generateur_ecs.spec.js diff --git a/src/14_generateur_ecs.js b/src/14_generateur_ecs.js index 08b75da..a009ff4 100644 --- a/src/14_generateur_ecs.js +++ b/src/14_generateur_ecs.js @@ -165,14 +165,14 @@ function type_generateur_ecs(di, de, du, usage_generateur) { return type_generateur; } -function rg_chauffe_eau_gaz(di, besoin_ecs) { +export function rg_chauffe_eau_gaz(di, besoin_ecs) { return ( 1 / (1 / di.rpn + 1790 * (di.qp0 / (besoin_ecs * 1000)) + 6970 * (di.pveil / (besoin_ecs * 1000))) ); } -function rgrs_chaudiere(di, besoin_ecs) { +export function rgrs_chaudiere(di, besoin_ecs) { return ( 1 / (1 / di.rpn + @@ -181,7 +181,7 @@ function rgrs_chaudiere(di, besoin_ecs) { ); } -function rg_accumulateur_gaz(di, besoin_ecs) { +export function rg_accumulateur_gaz(di, besoin_ecs) { return ( 1 / (1 / di.rpn + @@ -190,7 +190,7 @@ function rg_accumulateur_gaz(di, besoin_ecs) { ); } -function rgrsReseauUrbain(de, ecs_de) { +export function rgrsReseauUrbain(de, ecs_de) { /** * Le rendement de génération d'un réseau de chaleur dépend de son caractère isolé ou non. * D'après la spécification 3CL-DPE, le type de RCU est porté par enum_type_generateur_ecs_id : diff --git a/src/14_generateur_ecs.spec.js b/src/14_generateur_ecs.spec.js new file mode 100644 index 0000000..fdca7eb --- /dev/null +++ b/src/14_generateur_ecs.spec.js @@ -0,0 +1,235 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +/** + * Dépendances mockées pour isoler `calc_gen_ecs` et ses fonctions de rendement : + * - `enums` : libellés des types de générateur / installation / zone ; + * - `utils` : `tv`, `tvColumnIDs`, `requestInput(ID)`, `Tbase` contrôlés, `bug_for_bug_compat` désactivé ; + * - `tv_generateur_combustion` : injecte les caractéristiques (rpn, qp0, pveil) du générateur ; + * - `scopOrCop` : injecte le SCOP pour les pompes à chaleur ; + * - `conso_aux_gen`, `updateGenerateurCombustion`, `getFicheTechnique` : neutralisés. + * Aucune vraie table de valeurs n'est utilisée : les tests restent stables si les données changent. + */ +vi.mock('./enums.js', () => ({ + default: { + type_generateur_ecs: { + 60: 'ballon électrique à accumulation vertical', + 61: 'ballon électrique à accumulation vertical catégorie c ou 3 étoiles', + gaz: 'chauffe-eau gaz', + chaud: 'chaudière gaz condensation', + accu: 'accumulateur gaz', + pac: 'pac double service', + autre: 'générateur non implémenté' + }, + type_generateur_ch: {}, + classe_altitude: { 1: 'ca1' }, + zone_climatique: { 1: 'h1a' }, + type_installation_solaire: { 1: 'installation solaire' } + } +})); + +vi.mock('./utils.js', () => ({ + bug_for_bug_compat: false, + getVolumeStockageFromDescription: vi.fn(), + requestInput: vi.fn((de, du, field) => de[field]), + requestInputID: vi.fn((de, du, field) => de[`enum_${field}_id`]), + Tbase: { ca1: { h1: -5 } }, + tv: vi.fn(), + tvColumnIDs: vi.fn(() => []) +})); + +vi.mock('./13.2_generateur_combustion.js', () => ({ + tv_generateur_combustion: vi.fn(), + updateGenerateurCombustion: vi.fn() +})); + +vi.mock('./15_conso_aux.js', () => ({ + conso_aux_gen: vi.fn() +})); + +vi.mock('./12.4_pac.js', () => ({ + scopOrCop: vi.fn() +})); + +vi.mock('./ficheTechnique.js', () => ({ + default: vi.fn() +})); + +const { + default: calc_gen_ecs, + rg_chauffe_eau_gaz, + rgrs_chaudiere, + rg_accumulateur_gaz, + rgrsReseauUrbain +} = await import('./14_generateur_ecs.js'); +const { tvColumnIDs, requestInput, requestInputID } = await import('./utils.js'); +const { tv_generateur_combustion } = await import('./13.2_generateur_combustion.js'); +const { scopOrCop } = await import('./12.4_pac.js'); + +/** + * 14. Générateur d'ECS : rendements et consommations + * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §14 + */ +describe('rendements de génération (fonctions pures)', () => { + const di = { rpn: 0.9, qp0: 0.1, pveil: 0.02, Qgw: 1000 }; + + test('rg_chauffe_eau_gaz : valeur de référence', () => { + // valeur de référence de régression + expect(rg_chauffe_eau_gaz(di, 100)).toBeCloseTo(0.8974283293793307, 9); + }); + + test('rgrs_chaudiere : valeur de référence (prend en compte Qgw et 0.5·pveil)', () => { + // valeur de référence de régression + expect(rgrs_chaudiere(di, 100)).toBeCloseTo(0.8899979361936746, 9); + }); + + test('rg_accumulateur_gaz : valeur de référence', () => { + // valeur de référence de régression + expect(rg_accumulateur_gaz(di, 100)).toBeCloseTo(0.8840973866670649, 9); + }); + + test('un besoin ECS plus grand rapproche le rendement du rendement à pleine charge', () => { + expect(rg_chauffe_eau_gaz(di, 100000)).toBeGreaterThan(rg_chauffe_eau_gaz(di, 100)); + }); +}); + +/** + * Réseau de chaleur : rendement forfaitaire selon le caractère isolé. + */ +describe('rgrsReseauUrbain - rendement d’un réseau de chaleur', () => { + test('type 73 (réseau isolé) : 0.9', () => { + expect(rgrsReseauUrbain({ enum_type_generateur_ecs_id: '73' }, {})).toBe(0.9); + }); + + test('réseau marqué isolé au niveau de l’installation : 0.9', () => { + expect( + rgrsReseauUrbain({ enum_type_generateur_ecs_id: '72' }, { reseau_distribution_isole: 1 }) + ).toBe(0.9); + }); + + test('réseau non isolé par défaut : 0.75', () => { + expect( + rgrsReseauUrbain({ enum_type_generateur_ecs_id: '72' }, { reseau_distribution_isole: 0 }) + ).toBe(0.75); + }); +}); + +/** + * calc_gen_ecs : sélection de la branche de rendement selon le type d'énergie / générateur. + */ +describe('calc_gen_ecs - consommation par générateur', () => { + let ecs_di; + let ecs_de; + + beforeEach(() => { + vi.mocked(tvColumnIDs).mockReset().mockReturnValue([]); + vi.mocked(requestInput) + .mockReset() + .mockImplementation((de, du, field) => de[field]); + vi.mocked(requestInputID) + .mockReset() + .mockImplementation((de, du, field) => de[`enum_${field}_id`]); + vi.mocked(tv_generateur_combustion).mockReset(); + vi.mocked(scopOrCop).mockReset(); + + ecs_di = { besoin_ecs: 100, besoin_ecs_depensier: 150, rendement_distribution: 0.9 }; + ecs_de = { enum_type_installation_id: '1' }; + }); + + /** Données d'entrée d'un générateur, avec absence de stockage par défaut (Qgw = 0). */ + function gen(deExtra) { + return { + donnee_entree: { + usage_generateur: 'ecs', + type_stockage_ecs: "abscence de stockage d'ecs (production instantanée)", + volume_stockage: 0, + ...deExtra + } + }; + } + + test('générateur électrique sans stockage (Qgw = 0) : conso = besoin / rd', () => { + const g = gen({ type_energie: 'électricité', enum_type_generateur_ecs_id: '60' }); + calc_gen_ecs({}, g, ecs_di, ecs_de, 0, '1', '1', 'maison'); + + expect(g.donnee_intermediaire.rendement_stockage).toBeCloseTo(1, 9); + // valeur de référence de régression : 100 / 0.9 + expect(g.donnee_intermediaire.conso_ecs).toBeCloseTo(111.11111111111111, 9); + expect(g.donnee_intermediaire.conso_ecs_depensier).toBeCloseTo(166.66666666666666, 9); + }); + + test('ballon électrique 3 étoiles : bonus de 8 % sur le rendement de stockage', () => { + const g = gen({ type_energie: 'électricité', enum_type_generateur_ecs_id: '61' }); + calc_gen_ecs({}, g, ecs_di, ecs_de, 0, '1', '1', 'maison'); + + expect(g.donnee_intermediaire.rendement_stockage).toBeCloseTo(1.08, 9); + // valeur de référence de régression : 100 / (1.08 * 0.9) + expect(g.donnee_intermediaire.conso_ecs).toBeCloseTo(102.88065843621398, 9); + }); + + test('réseau de chaleur isolé (type 73) : rendement 0.9', () => { + const g = gen({ + type_energie: 'réseau de chauffage urbain', + enum_type_generateur_ecs_id: '73' + }); + calc_gen_ecs({}, g, ecs_di, ecs_de, 0, '1', '1', 'immeuble'); + + expect(g.donnee_intermediaire.rendement_generation_stockage).toBe(0.9); + // valeur de référence de régression : 100 / (0.9 * 0.9) + expect(g.donnee_intermediaire.conso_ecs).toBeCloseTo(123.45679012345678, 9); + }); + + test('pompe à chaleur : consommation pilotée par le SCOP', () => { + vi.mocked(tvColumnIDs).mockImplementation((table) => (table === 'scop' ? ['pac'] : [])); + vi.mocked(scopOrCop).mockImplementation((di) => { + di.scop = 3; + }); + const g = gen({ type_energie: 'électricité', enum_type_generateur_ecs_id: 'pac' }); + calc_gen_ecs({}, g, ecs_di, ecs_de, 0, '1', '1', 'maison'); + + expect(scopOrCop).toHaveBeenCalled(); + // valeur de référence de régression : 100 / 3 / 0.9 + expect(g.donnee_intermediaire.conso_ecs).toBeCloseTo(37.03703703703703, 9); + }); + + test('chauffe-eau gaz (combustion) : rendement calculé par rg_chauffe_eau_gaz', () => { + vi.mocked(tvColumnIDs).mockImplementation((table) => + table === 'generateur_combustion' ? ['gaz'] : [] + ); + vi.mocked(tv_generateur_combustion).mockImplementation((dpe, di) => { + di.rpn = 0.9; + di.qp0 = 0.1; + di.pveil = 0.02; + di.pveilleuse = 1; + di.pn = 5000; + }); + const g = gen({ + type_energie: 'gaz', + enum_type_generateur_ecs_id: 'gaz', + enum_methode_saisie_carac_sys_id: '1' + }); + calc_gen_ecs({}, g, ecs_di, ecs_de, 0, '1', '1', 'maison'); + + expect(g.donnee_intermediaire.rendement_generation).toBeCloseTo(0.8974283293793307, 9); + // valeur de référence de régression : 100 / (0.8974283293793307 * 0.9) + expect(g.donnee_intermediaire.conso_ecs).toBeCloseTo(123.81056790123455, 9); + }); + + test('installation solaire : la couverture solaire réduit la consommation', () => { + ecs_de.enum_type_installation_solaire_id = '1'; + ecs_de.fecs_saisi = 0.5; + const g = gen({ type_energie: 'électricité', enum_type_generateur_ecs_id: '60' }); + calc_gen_ecs({}, g, ecs_di, ecs_de, 0, '1', '1', 'maison'); + + expect(ecs_di.fecs).toBe(0.5); + // valeur de référence de régression : 100 * (1 - 0.5) / 0.9 + expect(g.donnee_intermediaire.conso_ecs).toBeCloseTo(55.55555555555556, 9); + }); + + test('type d’énergie non pris en charge : rendement neutre (Iecs = 1)', () => { + const g = gen({ type_energie: 'bois', enum_type_generateur_ecs_id: 'autre' }); + calc_gen_ecs({}, g, ecs_di, ecs_de, 0, '1', '1', 'maison'); + + // aucune branche spécifique : conso = besoin / rd + expect(g.donnee_intermediaire.conso_ecs).toBeCloseTo(111.11111111111111, 9); + }); +}); From 92e9f1769d5aeade44b4d1c63b7e10dbf8605f2c Mon Sep 17 00:00:00 2001 From: Florent Perinel Date: Mon, 13 Jul 2026 17:00:58 +0200 Subject: [PATCH 11/20] test: add unit test for qualite isolation --- src/2021_04_13_qualite_isolation.js | 2 +- src/2021_04_13_qualite_isolation.spec.js | 261 +++++++++++++++++++++++ 2 files changed, 262 insertions(+), 1 deletion(-) create mode 100644 src/2021_04_13_qualite_isolation.spec.js diff --git a/src/2021_04_13_qualite_isolation.js b/src/2021_04_13_qualite_isolation.js index cb7a7da..4e3a476 100644 --- a/src/2021_04_13_qualite_isolation.js +++ b/src/2021_04_13_qualite_isolation.js @@ -1,7 +1,7 @@ /** * @returns {number} */ -function qualite_isol(dep, surface, seuil1, seuil2, seuil3) { +export function qualite_isol(dep, surface, seuil1, seuil2, seuil3) { /** * 1 - Très bonne * 2 - Bonne diff --git a/src/2021_04_13_qualite_isolation.spec.js b/src/2021_04_13_qualite_isolation.spec.js new file mode 100644 index 0000000..d15a735 --- /dev/null +++ b/src/2021_04_13_qualite_isolation.spec.js @@ -0,0 +1,261 @@ +import { describe, expect, test } from 'vitest'; + +/** + * Module purement calculatoire (aucune dépendance externe) : on teste + * directement les seuils de qualité et l'agrégation des déperditions. + */ +const { default: calc_qualite_isolation, qualite_isol } = await import( + './2021_04_13_qualite_isolation.js' +); + +/** + * Qualité de l'isolation + * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - annexe qualité d'isolation + */ +describe('qualite_isol - classement selon les seuils U', () => { + const seuils = [0.3, 0.45, 0.65]; + + test('déperdition ou surface nulle : U = 0 => très bonne (1)', () => { + expect(qualite_isol(0, 100, ...seuils)).toBe(1); + expect(qualite_isol(50, 0, ...seuils)).toBe(1); + }); + + test('U strictement inférieur au seuil 1 : très bonne (1)', () => { + // U = 20 / 100 = 0.2 < 0.3 + expect(qualite_isol(20, 100, ...seuils)).toBe(1); + }); + + test('U dans [seuil1, seuil2[ : bonne (2)', () => { + // U = 0.4 + expect(qualite_isol(40, 100, ...seuils)).toBe(2); + }); + + test('U dans [seuil2, seuil3[ : moyenne (3)', () => { + // U = 0.5 + expect(qualite_isol(50, 100, ...seuils)).toBe(3); + }); + + test('U supérieur ou égal au seuil 3 : insuffisante (4)', () => { + // U = 0.9 >= 0.65 + expect(qualite_isol(90, 100, ...seuils)).toBe(4); + }); + + test('les bornes sont exclusives par le bas (U === seuil => classe suivante)', () => { + // U === seuil1 (0.3) => n'est pas < seuil1 => classe 2 + expect(qualite_isol(30, 100, ...seuils)).toBe(2); + }); +}); + +/** Paroi générique surface/U. */ +function mur({ adjacence = '1', surface, umur, b = 1 }) { + return { + donnee_entree: { enum_type_adjacence_id: adjacence, surface_paroi_opaque: surface }, + donnee_intermediaire: { b, umur } + }; +} +function plancherBas({ adjacence = '1', surface, upb }) { + return { + donnee_entree: { enum_type_adjacence_id: adjacence, surface_paroi_opaque: surface }, + donnee_intermediaire: { upb } + }; +} +function plancherHaut({ adjacence = '1', typePh = '12', description, surface, uph }) { + return { + donnee_entree: { + enum_type_adjacence_id: adjacence, + enum_type_plancher_haut_id: typePh, + description, + surface_paroi_opaque: surface + }, + donnee_intermediaire: { uph } + }; +} +function baie({ surface, u, b = 1 }) { + return { + donnee_entree: { surface_totale_baie: surface, u_menuiserie: u }, + donnee_intermediaire: { b, u_menuiserie: u } + }; +} +function porte({ surface, u, b = 1 }) { + return { + donnee_entree: { surface_porte: surface, uporte: u }, + donnee_intermediaire: { b, uporte: u } + }; +} +function enveloppe({ murs = [], pb = [], ph = [], bv = [], portes = [] }) { + return { + mur_collection: { mur: murs }, + plancher_haut_collection: { plancher_haut: ph }, + plancher_bas_collection: { plancher_bas: pb }, + baie_vitree_collection: { baie_vitree: bv }, + porte_collection: { porte: portes } + }; +} + +describe('calc_qualite_isolation - agrégation et classement', () => { + test('cas de référence : ubat et classes de qualité', () => { + const env = enveloppe({ + murs: [mur({ surface: 100, umur: 0.5 })], + pb: [plancherBas({ surface: 50, upb: 0.4 })], + ph: [plancherHaut({ surface: 30, uph: 0.2 })], + bv: [baie({ surface: 20, u: 2 })], + portes: [porte({ surface: 5, u: 2 })] + }); + const dp = { + deperdition_mur: 50, + deperdition_plancher_bas: 20, + deperdition_plancher_haut: 6, + deperdition_baie_vitree: 40, + deperdition_porte: 10, + deperdition_pont_thermique: 10 + }; + + const ret = calc_qualite_isolation(env, dp); + + // valeur de référence de régression : 136 / 205 + expect(ret.ubat).toBeCloseTo(0.6634146341463415, 9); + expect(ret.qualite_isol_enveloppe).toBe(3); + expect(ret.qualite_isol_mur).toBe(3); // U = 0.5 + expect(ret.qualite_isol_plancher_bas).toBe(2); // U = 0.4 + expect(ret.qualite_isol_menuiserie).toBe(2); // U = 50/25 = 2 + expect(ret.qualite_isol_plancher_haut_comble_amenage).toBe(2); // U = 0.2 + }); + + test('les locaux non déperditifs (adjacence 22) sont exclus des murs et planchers bas', () => { + const env = enveloppe({ + murs: [mur({ surface: 100, umur: 0.5 }), mur({ adjacence: '22', surface: 999, umur: 9 })], + pb: [ + plancherBas({ surface: 50, upb: 0.4 }), + plancherBas({ adjacence: '22', surface: 999, upb: 9 }) + ] + }); + const dp = { + deperdition_mur: 50, + deperdition_plancher_bas: 20, + deperdition_plancher_haut: 0, + deperdition_baie_vitree: 0, + deperdition_porte: 0, + deperdition_pont_thermique: 0 + }; + + const ret = calc_qualite_isolation(env, dp); + + // surface déperditive = 100 + 50 = 150 ; ubat = 70 / 150 + expect(ret.ubat).toBeCloseTo(70 / 150, 9); + expect(ret.qualite_isol_mur).toBe(3); // 0.5 seule paroi retenue + }); + + test('un mur avec coefficient b nul est exclu du calcul', () => { + const env = enveloppe({ + murs: [mur({ surface: 100, umur: 0.5 }), mur({ surface: 200, umur: 9, b: 0 })] + }); + const dp = { + deperdition_mur: 50, + deperdition_plancher_bas: 0, + deperdition_plancher_haut: 0, + deperdition_baie_vitree: 0, + deperdition_porte: 0, + deperdition_pont_thermique: 0 + }; + + const ret = calc_qualite_isolation(env, dp); + + // seul le mur b>0 (surface 100) est compté + expect(ret.ubat).toBeCloseTo(0.5, 9); + expect(ret.qualite_isol_mur).toBe(3); + }); + + test("plancher haut non adjacent à l'extérieur (adjacence !== 1) : classé comble perdu", () => { + const env = enveloppe({ + ph: [plancherHaut({ adjacence: '5', surface: 40, uph: 0.1 })] + }); + const dp = { + deperdition_mur: 0, + deperdition_plancher_bas: 0, + deperdition_plancher_haut: 4, + deperdition_baie_vitree: 0, + deperdition_porte: 0, + deperdition_pont_thermique: 0 + }; + + const ret = calc_qualite_isolation(env, dp); + + // U = 4 / 40 = 0.1 < 0.15 => très bonne (1) + expect(ret.qualite_isol_plancher_haut_comble_perdu).toBe(1); + expect(ret.qualite_isol_plancher_haut_comble_amenage).toBeUndefined(); + expect(ret.qualite_isol_plancher_haut_toit_terrasse).toBeUndefined(); + }); + + test('plancher haut extérieur sans marqueur combles aménagés : classé toit terrasse', () => { + const env = enveloppe({ + ph: [ + plancherHaut({ + adjacence: '1', + typePh: '5', + description: 'dalle béton', + surface: 40, + uph: 0.28 + }) + ] + }); + const dp = { + deperdition_mur: 0, + deperdition_plancher_bas: 0, + deperdition_plancher_haut: 11.2, + deperdition_baie_vitree: 0, + deperdition_porte: 0, + deperdition_pont_thermique: 0 + }; + + const ret = calc_qualite_isolation(env, dp); + + // U = 0.28 : seuils toit terrasse 0.25 / 0.3 / 0.35 => classe 2 (dans [0.25, 0.3[) + expect(ret.qualite_isol_plancher_haut_toit_terrasse).toBe(2); + }); + + test('description contenant « combles aménagés » : classé combles aménagés', () => { + const env = enveloppe({ + ph: [ + plancherHaut({ + adjacence: '1', + typePh: '5', + description: 'Combles aménagés sous rampant', + surface: 30, + uph: 0.2 + }) + ] + }); + const dp = { + deperdition_mur: 0, + deperdition_plancher_bas: 0, + deperdition_plancher_haut: 6, + deperdition_baie_vitree: 0, + deperdition_porte: 0, + deperdition_pont_thermique: 0 + }; + + const ret = calc_qualite_isolation(env, dp); + + expect(ret.qualite_isol_plancher_haut_comble_amenage).toBe(2); + }); + + test('collections vides : ubat non fini, seules les quatre qualités de base présentes', () => { + const env = enveloppe({}); + const dp = { + deperdition_mur: 0, + deperdition_plancher_bas: 0, + deperdition_plancher_haut: 0, + deperdition_baie_vitree: 0, + deperdition_porte: 0, + deperdition_pont_thermique: 0 + }; + + const ret = calc_qualite_isolation(env, dp); + + // 0 / 0 => NaN + expect(Number.isFinite(ret.ubat)).toBe(false); + expect(ret.qualite_isol_plancher_haut_comble_amenage).toBeUndefined(); + expect(ret.qualite_isol_plancher_haut_toit_terrasse).toBeUndefined(); + expect(ret.qualite_isol_plancher_haut_comble_perdu).toBeUndefined(); + }); +}); From 7bf297f1dd1c75ab39a54c917f8460cf38fb42bf Mon Sep 17 00:00:00 2001 From: Florent Perinel Date: Mon, 13 Jul 2026 17:01:14 +0200 Subject: [PATCH 12/20] test: add unit test for plancher haut --- src/3.2.3_plancher_haut.spec.js | 202 ++++++++++++++++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 src/3.2.3_plancher_haut.spec.js diff --git a/src/3.2.3_plancher_haut.spec.js b/src/3.2.3_plancher_haut.spec.js new file mode 100644 index 0000000..be57575 --- /dev/null +++ b/src/3.2.3_plancher_haut.spec.js @@ -0,0 +1,202 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +/** + * Dépendances mockées pour isoler `calc_ph` : + * - `enums` : mapping minimal période de construction / période d'isolation ; + * - `b` (3.1_b.js) : simple espion (le coefficient b n'intervient pas dans le calcul de Uph ici) ; + * - `tv` : accès aux tables `uph0` / `uph`, on contrôle la ligne retournée par table ; + * - `requestInput` : passe-plat renvoyant la donnée d'entrée demandée ; + * - `getKeyByValue` : implémentation déterministe (pas de dépendance aux vraies enums) ; + * - `bug_for_bug_compat` : désactivé pour isoler le comportement nominal. + */ +vi.mock('./enums.js', () => ({ + default: { + periode_construction: { 1: 'avant 1948', 5: '1989-2000' }, + periode_isolation: { 2: '1975-1977', 6: '2006-2012' } + } +})); + +vi.mock('./3.1_b.js', () => ({ + default: vi.fn() +})); + +vi.mock('./utils.js', () => ({ + tv: vi.fn(), + requestInput: (de, du, field) => de[field], + getKeyByValue: (object, value) => Object.keys(object).find((key) => object[key] === value), + bug_for_bug_compat: false +})); + +const { default: calc_ph } = await import('./3.2.3_plancher_haut.js'); +const { tv } = await import('./utils.js'); + +/** Table `uph0` figée : U0 forfaitaire de la paroi nue. */ +const ROW_UPH0 = { uph0: '2', tv_uph0_id: '5' }; +/** Table `uph` figée : U forfaitaire de la paroi isolée. */ +const ROW_UPH = { uph: '0.3', tv_uph_id: '7' }; + +beforeEach(() => { + tv.mockReset(); + tv.mockImplementation((table) => { + if (table === 'uph0') return { ...ROW_UPH0 }; + if (table === 'uph') return { ...ROW_UPH }; + return null; + }); +}); + +/** + * 3.2.3 Coefficient de transmission des planchers hauts (Uph) + * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §3.2.3 + */ +describe('calc_ph - plancher non isolé', () => { + test('Uph égal à Uph0 (paroi nue)', () => { + const ph = { + donnee_entree: { + methode_saisie_u: 'non isolé', + methode_saisie_u0: 'type de paroi inconnu (valeur par défaut)', + type_plancher_haut: 'terrasse' + } + }; + + calc_ph(ph, 'h1a', '1', '0'); + + expect(ph.donnee_intermediaire.uph0).toBe(2); + expect(ph.donnee_intermediaire.uph).toBe(2); + }); +}); + +describe("calc_ph - ajout d'une isolation par épaisseur ou résistance", () => { + test("épaisseur d'isolation saisie : Uph = 1 / (1/Uph0 + e/0,04)", () => { + const ph = { + donnee_entree: { + methode_saisie_u: 'epaisseur isolation saisie justifiée par mesure ou observation', + methode_saisie_u0: 'type de paroi inconnu (valeur par défaut)', + type_plancher_haut: 'terrasse', + epaisseur_isolation: 10 + } + }; + + calc_ph(ph, 'h1a', '1', '0'); + + // Uph0 = 2 ; e = 10 * 0,01 = 0,1 m ; Uph = 1 / (1/2 + 0,1/0,04) = 1/3 + // valeur de référence de régression + expect(ph.donnee_intermediaire.uph).toBeCloseTo(0.3333333333333333, 9); + }); + + test("résistance d'isolation saisie : Uph = 1 / (1/Uph0 + R)", () => { + const ph = { + donnee_entree: { + methode_saisie_u: + "resistance isolation saisie justifiée observation de l'isolant installé et mesure de son épaisseur", + methode_saisie_u0: 'type de paroi inconnu (valeur par défaut)', + type_plancher_haut: 'terrasse', + resistance_isolation: 1 + } + }; + + calc_ph(ph, 'h1a', '1', '0'); + + // Uph0 = 2 ; R = 1 ; Uph = 1 / (1/2 + 1) = 2/3 + // valeur de référence de régression + expect(ph.donnee_intermediaire.uph).toBeCloseTo(0.6666666666666666, 9); + }); +}); + +describe('calc_ph - saisie directe de Uph', () => { + test('Uph pris depuis la valeur saisie, sans accès aux tables', () => { + const ph = { + donnee_entree: { + methode_saisie_u: + 'saisie direct u justifiée (à partir des documents justificatifs autorisés)', + uph_saisi: 0.18 + } + }; + + calc_ph(ph, 'h1a', '1', '0'); + + expect(tv).not.toHaveBeenCalled(); + expect(ph.donnee_intermediaire.uph).toBe(0.18); + }); +}); + +describe('calc_ph - isolation inconnue (table forfaitaire)', () => { + test('Uph = min(Uph table, Uph0) : la paroi isolée ne peut être plus déperditive que nue', () => { + const ph = { + donnee_entree: { + methode_saisie_u: 'isolation inconnue (table forfaitaire)', + methode_saisie_u0: 'type de paroi inconnu (valeur par défaut)', + type_adjacence: 'extérieur', + type_plancher_haut: 'terrasse' + } + }; + + calc_ph(ph, 'h1a', '1', '0'); + + // min(0,3 ; 2) = 0,3 + expect(ph.donnee_intermediaire.uph).toBe(0.3); + }); + + /** + * Détermination du type de toiture (matcher de la table `uph`) selon l'adjacence. + * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - page 21 + */ + test.each([ + ['locaux non chauffés non accessible', 'terrasse', 'terrasse'], + ['garage', 'terrasse', 'combles'], + ['extérieur', 'combles aménagés sous rampant', 'combles'], + ['extérieur', 'terrasse', 'terrasse'] + ])( + 'adjacence "%s" + type "%s" => type_toiture "%s" dans le matcher', + (typeAdjacence, typePlancherHaut, typeToitureAttendu) => { + const ph = { + donnee_entree: { + methode_saisie_u: 'isolation inconnue (table forfaitaire)', + methode_saisie_u0: 'type de paroi inconnu (valeur par défaut)', + type_adjacence: typeAdjacence, + type_plancher_haut: typePlancherHaut + } + }; + + calc_ph(ph, 'h1a', '1', '0'); + + expect(tv).toHaveBeenCalledWith('uph', { + enum_periode_construction_id: '1', + enum_zone_climatique_id: 'h1a', + effet_joule: '0', + type_toiture: typeToitureAttendu + }); + } + ); + + test("la période d'isolation, si présente, prime sur la période de construction", () => { + const ph = { + donnee_entree: { + methode_saisie_u: 'isolation inconnue (table forfaitaire)', + methode_saisie_u0: 'type de paroi inconnu (valeur par défaut)', + type_adjacence: 'extérieur', + type_plancher_haut: 'terrasse', + enum_periode_isolation_id: '6' + } + }; + + calc_ph(ph, 'h1a', '1', '0'); + + expect(tv).toHaveBeenCalledWith( + 'uph', + expect.objectContaining({ enum_periode_construction_id: '6' }) + ); + }); +}); + +describe('calc_ph - méthode de saisie inconnue', () => { + test('aucun calcul de Uph et avertissement émis', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const ph = { donnee_entree: { methode_saisie_u: 'valeur inexistante' } }; + + calc_ph(ph, 'h1a', '1', '0'); + + expect(ph.donnee_intermediaire.uph).toBeUndefined(); + expect(warn).toHaveBeenCalled(); + warn.mockRestore(); + }); +}); From e21afeb6ca013670d52ee040a91fdbbc133a7732 Mon Sep 17 00:00:00 2001 From: Florent Perinel Date: Mon, 13 Jul 2026 17:01:20 +0200 Subject: [PATCH 13/20] test: add unit test for porte --- src/3.3.1.4_porte.spec.js | 114 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 src/3.3.1.4_porte.spec.js diff --git a/src/3.3.1.4_porte.spec.js b/src/3.3.1.4_porte.spec.js new file mode 100644 index 0000000..c918ad4 --- /dev/null +++ b/src/3.3.1.4_porte.spec.js @@ -0,0 +1,114 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +/** + * Dépendances mockées pour isoler `calc_porte` : + * - `b` (3.1_b.js) : calcul du coefficient de réduction des déperditions, ici un simple espion ; + * - `tv` : accès à la table de valeurs `uporte` (on contrôle la ligne retournée) ; + * - `requestInput` : passe-plat qui renvoie la donnée d'entrée demandée. + */ +vi.mock('./3.1_b.js', () => ({ + default: vi.fn() +})); + +vi.mock('./utils.js', () => ({ + tv: vi.fn(), + requestInput: (de, du, field) => de[field] +})); + +const { default: calc_porte } = await import('./3.3.1.4_porte.js'); +const { default: b } = await import('./3.1_b.js'); +const { tv } = await import('./utils.js'); + +beforeEach(() => { + vi.mocked(b).mockReset(); + vi.mocked(tv).mockReset(); +}); + +/** + * 3.3.1.4 Coefficient de transmission surfacique des portes (Uporte) + * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §3.3.1.4 + */ +describe('calc_porte - coefficient de transmission des portes', () => { + test('valeur forfaitaire : Uporte lu dans la table via le type de porte', () => { + tv.mockReturnValue({ uporte: '2.4', tv_uporte_id: '15' }); + const porte = { + donnee_entree: { + surface_porte: 2, + methode_saisie_uporte: 'valeur forfaitaire', + enum_type_porte_id: '3' + } + }; + + calc_porte(porte, 1); + + expect(tv).toHaveBeenCalledWith('uporte', { enum_type_porte_id: '3' }, porte.donnee_entree); + expect(porte.donnee_intermediaire.uporte).toBe(2.4); + expect(porte.donnee_entree.tv_uporte_id).toBe(15); + }); + + test('saisie directe : Uporte pris depuis la valeur saisie, sans accès table', () => { + const porte = { + donnee_entree: { + surface_porte: 2, + methode_saisie_uporte: 'saisie directe', + uporte_saisi: 1.8 + } + }; + + calc_porte(porte, 1); + + expect(tv).not.toHaveBeenCalled(); + expect(porte.donnee_intermediaire.uporte).toBe(1.8); + }); + + test('le coefficient b est calculé avec la zone climatique fournie', () => { + tv.mockReturnValue({ uporte: '2.4', tv_uporte_id: '15' }); + const porte = { + donnee_entree: { + surface_porte: 2, + methode_saisie_uporte: 'valeur forfaitaire', + enum_type_porte_id: '3' + } + }; + + calc_porte(porte, 8); + + expect(b).toHaveBeenCalledWith( + porte.donnee_intermediaire, + porte.donnee_entree, + porte.donnee_utilisateur, + 8 + ); + }); + + test('aucune valeur forfaitaire trouvée : Uporte reste indéfini', () => { + tv.mockReturnValue(null); + const porte = { + donnee_entree: { + surface_porte: 2, + methode_saisie_uporte: 'valeur forfaitaire', + enum_type_porte_id: '3' + } + }; + + calc_porte(porte, 1); + + expect(porte.donnee_intermediaire.uporte).toBeUndefined(); + }); + + test('les objets donnee_utilisateur et donnee_intermediaire sont attachés à la porte', () => { + tv.mockReturnValue({ uporte: '2.4', tv_uporte_id: '15' }); + const porte = { + donnee_entree: { + surface_porte: 2, + methode_saisie_uporte: 'valeur forfaitaire', + enum_type_porte_id: '3' + } + }; + + calc_porte(porte, 1); + + expect(porte.donnee_utilisateur).toBeDefined(); + expect(porte.donnee_intermediaire).toBeDefined(); + }); +}); From cd9f1e55a11a0b55ca5d57e886949b63cfd3473d Mon Sep 17 00:00:00 2001 From: Florent Perinel Date: Mon, 13 Jul 2026 17:01:27 +0200 Subject: [PATCH 14/20] test: add unit test for pont thermique --- src/3.4_pont_thermique.spec.js | 167 +++++++++++++++++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 src/3.4_pont_thermique.spec.js diff --git a/src/3.4_pont_thermique.spec.js b/src/3.4_pont_thermique.spec.js new file mode 100644 index 0000000..3ce7f65 --- /dev/null +++ b/src/3.4_pont_thermique.spec.js @@ -0,0 +1,167 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +/** + * Dépendances mockées pour isoler `calc_pont_thermique` : + * - `enums` : mapping minimal période de construction ; + * - `isNil` (lodash-es) : implémentation déterministe ; + * - `tv` : accès à la table `pont_thermique`, on contrôle la ligne retournée ; + * - `requestInput` : passe-plat vers les données d'entrée ; + * - `compareReferences` : comparaison stricte des références (pas de nettoyage réel) ; + * - `bug_for_bug_compat` : désactivé pour isoler le comportement nominal. + */ +vi.mock('./enums.js', () => ({ + default: { + periode_construction: { 1: 'avant 1948', 5: '1989-2000' } + } +})); + +vi.mock('lodash-es', () => ({ + isNil: (value) => value === null || value === undefined +})); + +vi.mock('./utils.js', () => ({ + tv: vi.fn(), + requestInput: (de, du, field) => de[field], + compareReferences: (a, b) => a === b, + bug_for_bug_compat: false +})); + +const { default: calc_pont_thermique } = await import('./3.4_pont_thermique.js'); +const { tv } = await import('./utils.js'); + +/** Enveloppe minimale avec collections contrôlées. */ +function logement({ mur = [] } = {}) { + return { + enveloppe: { + mur_collection: { mur }, + plancher_bas_collection: { plancher_bas: [] }, + plancher_haut_collection: { plancher_haut: [] }, + baie_vitree_collection: { baie_vitree: [] }, + porte_collection: { porte: [] } + } + }; +} + +let errorSpy; +let warnSpy; + +beforeEach(() => { + tv.mockReset(); + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); +}); + +afterEach(() => { + errorSpy.mockRestore(); + warnSpy.mockRestore(); +}); + +/** + * 3.4 Calcul des déperditions par les ponts thermiques + * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §3.4 + */ +describe('calc_pont_thermique - méthode de saisie directe', () => { + test('k justifié saisi (méthode 2) : k pris directement', () => { + const pt = { donnee_entree: { enum_methode_saisie_pont_thermique_id: '2', k_saisi: 0.5 } }; + calc_pont_thermique(pt, '1', logement()); + expect(pt.donnee_intermediaire.k).toBe(0.5); + expect(tv).not.toHaveBeenCalled(); + }); + + test('k saisi absent (méthode 3) : erreur et k non défini', () => { + const pt = { donnee_entree: { enum_methode_saisie_pont_thermique_id: '3' } }; + calc_pont_thermique(pt, '1', logement()); + expect(pt.donnee_intermediaire.k).toBeUndefined(); + expect(errorSpy).toHaveBeenCalled(); + }); + + test('méthode de saisie non reconnue : erreur et k non défini', () => { + const pt = { donnee_entree: { enum_methode_saisie_pont_thermique_id: '9' } }; + calc_pont_thermique(pt, '1', logement()); + expect(pt.donnee_intermediaire.k).toBeUndefined(); + expect(errorSpy).toHaveBeenCalled(); + }); +}); + +describe('calc_pont_thermique - valeur forfaitaire (méthode 1)', () => { + test('liaison refend / mur sans mur associé : k lu dans la table avec isolation ITI par défaut', () => { + tv.mockReturnValue({ k: '0.5', tv_pont_thermique_id: '42' }); + const pt = { + donnee_entree: { + enum_methode_saisie_pont_thermique_id: '1', + enum_type_liaison_id: '12', + type_liaison: 'refend / mur', + reference_1: 'M1', + description: 'refend' + }, + donnee_intermediaire: { k: 0.9 } + }; + + calc_pont_thermique(pt, '1', logement()); + + expect(tv).toHaveBeenCalledWith('pont_thermique', { + enum_type_liaison_id: '12', + isolation_mur: '^iti' + }); + expect(pt.donnee_intermediaire.k).toBe(0.5); + expect(pt.donnee_entree.tv_pont_thermique_id).toBe(42); + }); + + /** + * Les ponts thermiques des murs de circulation avec k intermédiaire nul restent nuls. + * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §3.4 (locaux de circulation) + */ + test('mur de circulation (adjacence 22) avec k intermédiaire nul : k = 0 sans accès table', () => { + const mur = [ + { donnee_entree: { reference: 'M1', enum_type_adjacence_id: '22' }, donnee_utilisateur: {} } + ]; + const pt = { + donnee_entree: { + enum_methode_saisie_pont_thermique_id: '1', + enum_type_liaison_id: '12', + type_liaison: 'refend / mur', + reference_1: 'M1', + description: 'refend' + }, + donnee_intermediaire: { k: 0 } + }; + + calc_pont_thermique(pt, '1', logement({ mur })); + + expect(pt.donnee_intermediaire.k).toBe(0); + expect(tv).not.toHaveBeenCalled(); + }); + + /** + * 3.4.5 Menuiserie / mur : les ponts thermiques avec les parois en structure bois sont négligés. + * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §3.4.5 + */ + test('liaison menuiserie / mur sur structure bois : k = 0', () => { + const mur = [ + { + donnee_entree: { + reference: 'M1', + enum_type_adjacence_id: '1', + enum_materiaux_structure_mur_id: '5', + type_isolation: 'iti' + }, + donnee_utilisateur: {} + } + ]; + const pt = { + donnee_entree: { + enum_methode_saisie_pont_thermique_id: '1', + enum_type_liaison_id: '20', + type_liaison: 'menuiserie / mur', + reference_1: 'M1', + description: 'menuiserie' + }, + donnee_intermediaire: { k: 0.3 } + }; + + calc_pont_thermique(pt, '1', logement({ mur })); + + expect(pt.donnee_intermediaire.k).toBe(0); + expect(tv).not.toHaveBeenCalled(); + }); +}); From fd2909679fdee68e81bf6704d8f668e4d0a74eb7 Mon Sep 17 00:00:00 2001 From: Florent Perinel Date: Mon, 13 Jul 2026 17:01:36 +0200 Subject: [PATCH 15/20] test: add unit test for deperdition --- src/3_deperdition.spec.js | 219 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 219 insertions(+) create mode 100644 src/3_deperdition.spec.js diff --git a/src/3_deperdition.spec.js b/src/3_deperdition.spec.js new file mode 100644 index 0000000..1df4178 --- /dev/null +++ b/src/3_deperdition.spec.js @@ -0,0 +1,219 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +/** + * Dépendances mockées pour isoler l'orchestrateur `calc_deperdition` : + * - `enums` : mapping minimal des types d'adjacence (utilisé par le calcul de Sdep) ; + * - tous les modules de calcul par paroi (`calc_mur`, `calc_pb`, `calc_ph`, `calc_bv`, + * `calc_porte`, `calc_pont_thermique`, `calc_ventilation`) : espions no-op, les données + * intermédiaires sont donc pré-remplies dans les fixtures. + */ +vi.mock('./enums.js', () => ({ + default: { + type_adjacence: { + 1: 'extérieur', + 2: "local non déperditif (local à usage d'habitation chauffé)" + } + } +})); + +vi.mock('./3.2.1_mur.js', () => ({ default: vi.fn() })); +vi.mock('./3.2.2_plancher_bas.js', () => ({ default: vi.fn() })); +vi.mock('./3.2.3_plancher_haut.js', () => ({ default: vi.fn() })); +vi.mock('./3.3_baie_vitree.js', () => ({ default: vi.fn() })); +vi.mock('./3.3.1.4_porte.js', () => ({ default: vi.fn() })); +vi.mock('./3.4_pont_thermique.js', () => ({ default: vi.fn() })); +vi.mock('./4_ventilation.js', () => ({ default: vi.fn() })); + +const { + default: calc_deperdition, + Umur, + Uph, + Upb, + Ubv, + Uporte, + Upt +} = await import('./3_deperdition.js'); +const { default: calc_mur } = await import('./3.2.1_mur.js'); +const { default: calc_ventilation } = await import('./4_ventilation.js'); + +beforeEach(() => { + vi.mocked(calc_mur).mockReset(); + vi.mocked(calc_ventilation).mockReset(); +}); + +/** + * 3. Déperditions de l'enveloppe : déperditions surfaciques par paroi. + * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §3 + */ +describe('déperditions surfaciques par paroi (U * surface * b)', () => { + test('Umur = surface_paroi_opaque * umur * b', () => { + const mur = { + donnee_entree: { surface_paroi_opaque: 10 }, + donnee_intermediaire: { umur: 0.5, b: 1 } + }; + expect(Umur(mur)).toBeCloseTo(5, 9); + }); + + test('Uph = surface_paroi_opaque * uph * b', () => { + const ph = { + donnee_entree: { surface_paroi_opaque: 8 }, + donnee_intermediaire: { uph: 0.2, b: 1 } + }; + expect(Uph(ph)).toBeCloseTo(1.6, 9); + }); + + test('Upb = surface_paroi_opaque * upb_final * b', () => { + const pb = { + donnee_entree: { surface_paroi_opaque: 12 }, + donnee_intermediaire: { upb_final: 0.3, b: 1 } + }; + expect(Upb(pb)).toBeCloseTo(3.6, 9); + }); + + test('Ubv = surface_totale_baie * u_menuiserie * b', () => { + const bv = { + donnee_entree: { surface_totale_baie: 5 }, + donnee_intermediaire: { u_menuiserie: 2, b: 1 } + }; + expect(Ubv(bv)).toBeCloseTo(10, 9); + }); + + test('Uporte = surface_porte * uporte * b', () => { + const porte = { + donnee_entree: { surface_porte: 2 }, + donnee_intermediaire: { uporte: 1.5, b: 1 } + }; + expect(Uporte(porte)).toBeCloseTo(3, 9); + }); + + test('un coefficient de réduction b nul annule la déperdition de la paroi', () => { + const mur = { + donnee_entree: { surface_paroi_opaque: 10 }, + donnee_intermediaire: { umur: 0.5, b: 0 } + }; + expect(Umur(mur)).toBe(0); + }); +}); + +/** + * 3.4 Déperdition d'un pont thermique : Upt = l * k * pourcentage. + * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §3.4 + */ +describe('Upt - déperdition linéique des ponts thermiques', () => { + test("Upt = l * k lorsque le pourcentage n'est pas renseigné (défaut 1)", () => { + const pt = { donnee_entree: { l: 4 }, donnee_intermediaire: { k: 0.5 } }; + expect(Upt(pt)).toBeCloseTo(2, 9); + }); + + test('le pourcentage de prise en compte du pont thermique est appliqué', () => { + const pt = { + donnee_entree: { l: 4, pourcentage_valeur_pont_thermique: 0.5 }, + donnee_intermediaire: { k: 0.5 } + }; + expect(Upt(pt)).toBeCloseTo(1, 9); + }); +}); + +/** Fabrique un DPE minimal avec une enveloppe pré-calculée. */ +function makeDpe({ + mur = [], + pb = [], + ph = [], + bv = [], + porte = [], + pt = [], + ventilation = [], + deperdition_mur = 0 +} = {}) { + return { + numero_dpe: 'TEST', + logement: { + sortie: { deperdition: { deperdition_mur } }, + enveloppe: { + mur_collection: { mur }, + plancher_bas_collection: { plancher_bas: pb }, + plancher_haut_collection: { plancher_haut: ph }, + porte_collection: { porte }, + baie_vitree_collection: { baie_vitree: bv }, + pont_thermique_collection: { pont_thermique: pt } + }, + ventilation_collection: { ventilation } + } + }; +} + +describe("calc_deperdition - agrégation des déperditions de l'enveloppe", () => { + test('une enveloppe vide donne des déperditions nulles', () => { + const ret = calc_deperdition( + { enum_periode_construction_id: '1' }, + 'h1a', + 'th', + '0', + makeDpe(), + 100 + ); + + expect(ret.deperdition_enveloppe).toBe(0); + expect(ret.deperdition_renouvellement_air).toBe(0); + }); + + test("agrège les déperditions surfaciques, linéiques et de renouvellement d'air", () => { + const mur = [ + { + donnee_entree: { surface_paroi_opaque: 10, enum_type_adjacence_id: '1' }, + donnee_intermediaire: { umur: 0.5, b: 1 } + } + ]; + const ph = [ + { + donnee_entree: { surface_paroi_opaque: 8, enum_type_adjacence_id: '1' }, + donnee_intermediaire: { uph: 0.2, b: 1 } + } + ]; + const pb = [ + { + donnee_entree: { surface_paroi_opaque: 12 }, + donnee_intermediaire: { upb_final: 0.3, b: 1 } + } + ]; + const bv = [ + { donnee_entree: { surface_totale_baie: 5 }, donnee_intermediaire: { u_menuiserie: 2, b: 1 } } + ]; + const porte = [ + { donnee_entree: { surface_porte: 2 }, donnee_intermediaire: { uporte: 1.5, b: 1 } } + ]; + const pt = [{ donnee_entree: { l: 4 }, donnee_intermediaire: { k: 0.5 } }]; + const ventilation = [{ donnee_intermediaire: { hvent: 40, hperm: 20 } }]; + + const dpe = makeDpe({ mur, ph, pb, bv, porte, pt, ventilation, deperdition_mur: 5 }); + const ret = calc_deperdition({ enum_periode_construction_id: '1' }, 'h1a', 'th', '0', dpe, 100); + + expect(ret.deperdition_mur).toBeCloseTo(5, 9); + expect(ret.deperdition_plancher_haut).toBeCloseTo(1.6, 9); + expect(ret.deperdition_plancher_bas).toBeCloseTo(3.6, 9); + expect(ret.deperdition_baie_vitree).toBeCloseTo(10, 9); + expect(ret.deperdition_porte).toBeCloseTo(3, 9); + expect(ret.deperdition_pont_thermique).toBeCloseTo(2, 9); + expect(ret.hvent).toBe(40); + expect(ret.hperm).toBe(20); + expect(ret.deperdition_renouvellement_air).toBe(60); + // 5 + 1,6 + 3,6 + 10 + 3 + 2 + 40 + 20 + expect(ret.deperdition_enveloppe).toBeCloseTo(85.2, 9); + }); + + test('chaque paroi est confiée à son module de calcul dédié', () => { + const mur = [ + { + donnee_entree: { surface_paroi_opaque: 10, enum_type_adjacence_id: '1' }, + donnee_intermediaire: { umur: 0.5, b: 1 } + } + ]; + const ventilation = [{ donnee_intermediaire: { hvent: 0, hperm: 0 } }]; + const dpe = makeDpe({ mur, ventilation, deperdition_mur: 5 }); + + calc_deperdition({ enum_periode_construction_id: '1' }, 'h1a', 'th', '0', dpe, 100); + + expect(calc_mur).toHaveBeenCalledTimes(1); + expect(calc_ventilation).toHaveBeenCalledTimes(1); + }); +}); From a1b7b05f2d16d8fc6f1215dd65155d8019954b49 Mon Sep 17 00:00:00 2001 From: Florent Perinel Date: Mon, 13 Jul 2026 17:02:04 +0200 Subject: [PATCH 16/20] test: add unit test for chauffage --- src/12.4_pac.spec.js | 102 +++++++++ src/13.2_generateur_combustion.spec.js | 206 ++++++++++++++++++ ....2_generateur_combustion_bouilleur.spec.js | 74 +++++++ src/13.2_generateur_combustion_ch.spec.js | 189 ++++++++++++++++ ....2_generateur_combustion_chaudiere.spec.js | 85 ++++++++ src/13.2_generateur_pac.spec.js | 64 ++++++ src/9_emetteur_ch.spec.js | 174 +++++++++++++++ .../models/type-generateur.model.spec.js | 26 +++ 8 files changed, 920 insertions(+) create mode 100644 src/12.4_pac.spec.js create mode 100644 src/13.2_generateur_combustion.spec.js create mode 100644 src/13.2_generateur_combustion_bouilleur.spec.js create mode 100644 src/13.2_generateur_combustion_ch.spec.js create mode 100644 src/13.2_generateur_combustion_chaudiere.spec.js create mode 100644 src/13.2_generateur_pac.spec.js create mode 100644 src/9_emetteur_ch.spec.js create mode 100644 src/features/dpe/domain/models/type-generateur.model.spec.js diff --git a/src/12.4_pac.spec.js b/src/12.4_pac.spec.js new file mode 100644 index 0000000..0295c30 --- /dev/null +++ b/src/12.4_pac.spec.js @@ -0,0 +1,102 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +/** + * Dépendances mockées pour isoler `scopOrCop` : + * - `tv` : accès à la table `scop` (on contrôle la ligne retournée) ; + * - `requestInputID` : simple passe-plat renvoyant l'identifiant demandé et le recopiant. + * Le comportement de ces utilitaires n'est pas testé ici, seulement la logique du module. + */ +vi.mock('./utils.js', () => ({ + tv: vi.fn(), + requestInputID: vi.fn((de, du, field) => { + const enumName = `enum_${field}_id`; + du[enumName] = de[enumName]; + return de[enumName]; + }) +})); + +const { scopOrCop } = await import('./12.4_pac.js'); +const { tv, requestInputID } = await import('./utils.js'); + +beforeEach(() => { + vi.mocked(tv).mockReset(); + vi.mocked(requestInputID).mockClear(); +}); + +/** + * 12.4 - Détermination du SCOP / COP des pompes à chaleur. + * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §12.4 + */ +describe('scopOrCop - méthode de saisie "caractéristiques saisies" (id 6)', () => { + test('SCOP renseigné : rg et rg_dep prennent la valeur du SCOP sans accès à la table', () => { + const di = { scop: 3.5 }; + const de = { enum_methode_saisie_carac_sys_id: '6' }; + scopOrCop(di, de, {}, 'zc', null, 'ch'); + expect(di.rg).toBe(3.5); + expect(di.rg_dep).toBe(3.5); + expect(tv).not.toHaveBeenCalled(); + }); + + test('SCOP absent : repli sur le COP', () => { + const di = { cop: 2.8 }; + const de = { enum_methode_saisie_carac_sys_id: '6' }; + scopOrCop(di, de, {}, 'zc', null, 'ch'); + expect(di.rg).toBe(2.8); + expect(di.rg_dep).toBe(2.8); + }); +}); + +describe('scopOrCop - valeur forfaitaire (table scop)', () => { + test('matcher sans identifiant d’émission : zone climatique + type de générateur', () => { + tv.mockReturnValue({ scop_ou_cop: 'scop', scop: '4.2', tv_scop_id: '15' }); + const di = {}; + const de = { enum_type_generateur_ch_id: '160' }; + + scopOrCop(di, de, {}, 'h1a', null, 'ch'); + + expect(tv).toHaveBeenCalledWith( + 'scop', + { + enum_zone_climatique_id: 'h1a', + enum_type_generateur_ch_id: '160' + }, + de + ); + expect(di.scop).toBe(4.2); + expect(di.rg).toBe(4.2); + expect(di.rg_dep).toBe(4.2); + expect(de.tv_scop_id).toBe(15); + }); + + test('identifiant d’émission fourni : ajouté au matcher', () => { + tv.mockReturnValue({ scop_ou_cop: 'cop', scop: '3.1', tv_scop_id: '20' }); + const di = {}; + const de = { enum_type_generateur_ecs_id: '10' }; + + scopOrCop(di, de, {}, 'h2b', '5', 'ecs'); + + expect(tv).toHaveBeenCalledWith( + 'scop', + { + enum_zone_climatique_id: 'h2b', + enum_type_generateur_ecs_id: '10', + enum_type_emission_distribution_id: '5' + }, + de + ); + // scop_ou_cop vaut 'cop' : la valeur est stockée sous di.cop + expect(di.cop).toBe(3.1); + expect(di.rg).toBe(3.1); + }); + + test('aucune ligne trouvée : ni rg ni rg_dep ne sont définis', () => { + tv.mockReturnValue(null); + const di = {}; + const de = { enum_type_generateur_ch_id: '160' }; + + scopOrCop(di, de, {}, 'h1a', null, 'ch'); + + expect(di.rg).toBeUndefined(); + expect(di.rg_dep).toBeUndefined(); + }); +}); diff --git a/src/13.2_generateur_combustion.spec.js b/src/13.2_generateur_combustion.spec.js new file mode 100644 index 0000000..01613de --- /dev/null +++ b/src/13.2_generateur_combustion.spec.js @@ -0,0 +1,206 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +/** + * Dépendances mockées pour isoler la logique du module : + * - `tv` : accès à la table `generateur_combustion` (ligne contrôlée) ; + * - `tvColumnLines` : liste des critères de puissance (vidée pour neutraliser le critère Pn) ; + * - `convertExpression` : non utilisée dans le chemin testé (critères vides) ; + * - `bug_for_bug_compat` : désactivé pour isoler le comportement nominal ; + * - `enums` : mapping minimal ; + * - les modules de substitution de générateurs (bouilleur / chaudière / pac) : simples espions ; + * - `getFicheTechnique` : présence d'un ventilateur. + * La bibliothèque `mathjs` (evaluate) n'est pas mockée : c'est une fonction pure déterministe. + */ +vi.mock('./utils.js', () => ({ + bug_for_bug_compat: false, + tv: vi.fn(), + tvColumnLines: vi.fn(() => []), + convertExpression: vi.fn((x) => x) +})); + +vi.mock('./enums.js', () => ({ + default: { + type_generateur_ch: { 89: 'chaudière gaz standard 2001-2015' }, + type_generateur_ecs: { 50: 'chaudière gaz standard' } + } +})); + +vi.mock('./13.2_generateur_combustion_bouilleur.js', () => ({ + updateGenerateurBouilleurs: vi.fn() +})); +vi.mock('./13.2_generateur_combustion_chaudiere.js', () => ({ + updateGenerateurChaudieres: vi.fn() +})); +vi.mock('./13.2_generateur_pac.js', () => ({ + updateGenerateurPacs: vi.fn() +})); +vi.mock('./ficheTechnique.js', () => ({ + default: vi.fn() +})); + +const { tv_generateur_combustion, updateGenerateurCombustion } = await import( + './13.2_generateur_combustion.js' +); +const { tv, tvColumnLines } = await import('./utils.js'); +const { updateGenerateurBouilleurs } = await import('./13.2_generateur_combustion_bouilleur.js'); +const { updateGenerateurChaudieres } = await import('./13.2_generateur_combustion_chaudiere.js'); +const { updateGenerateurPacs } = await import('./13.2_generateur_pac.js'); +const { default: getFicheTechnique } = await import('./ficheTechnique.js'); + +beforeEach(() => { + vi.mocked(tv).mockReset(); + vi.mocked(tvColumnLines).mockReset(); + vi.mocked(tvColumnLines).mockReturnValue([]); + vi.mocked(updateGenerateurBouilleurs).mockReset(); + vi.mocked(updateGenerateurChaudieres).mockReset(); + vi.mocked(updateGenerateurPacs).mockReset(); + vi.mocked(getFicheTechnique).mockReset(); +}); + +const ROW_DEFAUT = { + tv_generateur_combustion_id: '42', + rpn: '90', + rpint: '85', + qp0_perc: '5', + pveil: '10' +}; + +/** + * 13.2 - Récupération des caractéristiques forfaitaires d'un générateur à combustion. + * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §13.2 + */ +describe('tv_generateur_combustion - caractéristiques du générateur', () => { + test('puissance nominale calculée à partir de GV et Tbase lorsqu’elle est absente', () => { + tv.mockReturnValue({ ...ROW_DEFAUT }); + const di = {}; + const de = { enum_type_generateur_ch_id: '89', ratio_virtualisation: 1, presence_ventouse: 0 }; + tv_generateur_combustion({}, di, de, 'ch', 200, -9, 1); + // Pn = (1.2 * GV * (19 - Tbase)) / 0.95^3 -- valeur de référence de régression + expect(di.pn).toBeCloseTo(7837.877241580406, 9); + expect(de.tv_generateur_combustion_id).toBe(42); + }); + + test('puissance nominale déjà renseignée : non recalculée', () => { + tv.mockReturnValue({ ...ROW_DEFAUT }); + const di = { pn: 12345 }; + const de = { enum_type_generateur_ch_id: '89', ratio_virtualisation: 1, presence_ventouse: 0 }; + tv_generateur_combustion({}, di, de, 'ch', 200, -9, 1); + expect(di.pn).toBe(12345); + }); + + test('méthode forfaitaire (1) en chauffage : rpn, rpint, qp0 et pveil renseignés', () => { + tv.mockReturnValue({ ...ROW_DEFAUT }); + const di = { pn: 20000 }; + const de = { enum_type_generateur_ch_id: '89', ratio_virtualisation: 1, presence_ventouse: 0 }; + tv_generateur_combustion({}, di, de, 'ch', 200, -9, 1); + expect(di.rpn).toBeCloseTo(0.9, 9); + expect(di.rpint).toBeCloseTo(0.85, 9); + // qp0_perc constant '5' (ni Pn ni %) => 5 * 1000 * ratio + expect(di.qp0).toBe(5000); + expect(di.pveil).toBe(10); + }); + + test('type ECS : le rendement intermédiaire rpint n’est pas calculé', () => { + tv.mockReturnValue({ ...ROW_DEFAUT }); + const di = { pn: 20000 }; + const de = { enum_type_generateur_ecs_id: '50', ratio_virtualisation: 1, presence_ventouse: 0 }; + tv_generateur_combustion({}, di, de, 'ecs', 200, -9, 1); + expect(di.rpn).toBeCloseTo(0.9, 9); + expect(di.rpint).toBeUndefined(); + }); + + test('qp0_perc exprimé en fonction de Pn : qp0 proportionnel à la puissance nominale', () => { + tv.mockReturnValue({ ...ROW_DEFAUT, qp0_perc: 'Pn' }); + const di = { pn: 20000 }; + const de = { enum_type_generateur_ch_id: '89', ratio_virtualisation: 1, presence_ventouse: 0 }; + tv_generateur_combustion({}, di, de, 'ch', 200, -9, 1); + // qp0_calc = Pn(kW) = 20 ; inclut 'Pn' => qp0 = 20 * 1000 * ratio = pn + expect(di.qp0).toBe(20000); + }); + + test('présence d’une ventouse : sélection du couple (E, F) dans la formule de rendement', () => { + const diSansVentouse = { pn: 20000 }; + tv.mockReturnValue({ ...ROW_DEFAUT, rpn: 'E*40' }); + tv_generateur_combustion( + {}, + diSansVentouse, + { enum_type_generateur_ch_id: '89', ratio_virtualisation: 1, presence_ventouse: 0 }, + 'ch', + 200, + -9, + 1 + ); + // E = 2.5 (sans ventouse) => rpn = 2.5*40 / 100 = 1 + expect(diSansVentouse.rpn).toBeCloseTo(1, 9); + + const diVentouse = { pn: 20000 }; + tv.mockReturnValue({ ...ROW_DEFAUT, rpn: 'E*40' }); + tv_generateur_combustion( + {}, + diVentouse, + { enum_type_generateur_ch_id: '89', ratio_virtualisation: 1, presence_ventouse: 1 }, + 'ch', + 200, + -9, + 1 + ); + // E = 1.75 (avec ventouse) => rpn = 1.75*40 / 100 = 0.7 + expect(diVentouse.rpn).toBeCloseTo(0.7, 9); + }); + + test('méthode 4 : rpn, rpint et qp0 saisis sont conservés (non recalculés)', () => { + tv.mockReturnValue({ ...ROW_DEFAUT }); + const di = { pn: 20000, rpn: 0.5, rpint: 0.4, qp0: 999 }; + const de = { enum_type_generateur_ch_id: '89', ratio_virtualisation: 1, presence_ventouse: 0 }; + tv_generateur_combustion({}, di, de, 'ch', 200, -9, 4); + expect(di.rpn).toBe(0.5); + expect(di.rpint).toBe(0.4); + expect(di.qp0).toBe(999); + }); + + test('puissance de veilleuse saisie prise en compte hors méthode forfaitaire', () => { + tv.mockReturnValue({ ...ROW_DEFAUT }); + const di = { pn: 20000, pveilleuse: 15 }; + const de = { enum_type_generateur_ch_id: '89', ratio_virtualisation: 1, presence_ventouse: 0 }; + tv_generateur_combustion({}, di, de, 'ch', 200, -9, 2); + expect(di.pveil).toBe(15); + }); + + test('aucune ligne forfaitaire trouvée : arrêt sans identifiant de générateur', () => { + tv.mockReturnValue(undefined); + const di = { pn: 20000 }; + const de = { enum_type_generateur_ch_id: '89', ratio_virtualisation: 1, presence_ventouse: 0 }; + tv_generateur_combustion({}, di, de, 'ch', 200, -9, 1); + expect(de.tv_generateur_combustion_id).toBeUndefined(); + }); +}); + +/** + * 13.2 - Orchestration des substitutions de générateurs et enrichissement fiches techniques. + * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §13.2 + */ +describe('updateGenerateurCombustion - orchestration des substitutions', () => { + test('délègue aux trois routines de substitution avec les mêmes arguments', () => { + getFicheTechnique.mockReturnValue(undefined); + const dpe = {}; + const de = { description: 'gen' }; + updateGenerateurCombustion(dpe, de, 'ch'); + expect(updateGenerateurBouilleurs).toHaveBeenCalledWith(dpe, de, 'ch'); + expect(updateGenerateurChaudieres).toHaveBeenCalledWith(dpe, de, 'ch'); + expect(updateGenerateurPacs).toHaveBeenCalledWith(dpe, de, 'ch'); + }); + + test('présence d’un ventilateur signalée dans les fiches techniques : presenceVentilateur = 1', () => { + getFicheTechnique.mockReturnValue({ valeur: 'oui' }); + const de = { description: 'gen' }; + updateGenerateurCombustion({}, de, 'ch'); + expect(de.presenceVentilateur).toBe(1); + }); + + test('absence de ventilateur : presenceVentilateur non renseigné', () => { + getFicheTechnique.mockReturnValue({ valeur: 'non' }); + const de = { description: 'gen' }; + updateGenerateurCombustion({}, de, 'ch'); + expect(de.presenceVentilateur).toBeUndefined(); + }); +}); diff --git a/src/13.2_generateur_combustion_bouilleur.spec.js b/src/13.2_generateur_combustion_bouilleur.spec.js new file mode 100644 index 0000000..6f747b6 --- /dev/null +++ b/src/13.2_generateur_combustion_bouilleur.spec.js @@ -0,0 +1,74 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +/** + * Dépendance mockée : `getFicheTechnique` (import par défaut), qui fournit l'année + * d'installation du poêle bouilleur. On contrôle sa valeur de retour uniquement. + */ +vi.mock('./ficheTechnique.js', () => ({ + default: vi.fn() +})); + +const { updateGenerateurBouilleurs } = await import('./13.2_generateur_combustion_bouilleur.js'); +const { default: getFicheTechnique } = await import('./ficheTechnique.js'); + +beforeEach(() => { + vi.mocked(getFicheTechnique).mockReset(); +}); + +/** + * 13.1 - Les poêles à bois bouilleur sont assimilés à des chaudières bois de la + * période correspondante. + * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §13.1 + */ +describe('updateGenerateurBouilleurs - substitution par une chaudière bois équivalente', () => { + test('type non concerné : aucune substitution', () => { + getFicheTechnique.mockReturnValue(undefined); + const de = { enum_type_generateur_ch_id: '90' }; + updateGenerateurBouilleurs({}, de, 'ch'); + expect(de.enum_type_generateur_ch_id).toBe('90'); + }); + + test('sans fiche technique : période par défaut 2004-2012 (ch, bûche 48 -> 58)', () => { + getFicheTechnique.mockReturnValue(undefined); + const de = { enum_type_generateur_ch_id: '48' }; + updateGenerateurBouilleurs({}, de, 'ch'); + // Valeur par défaut : chaudière bois 2004-2012 (values[2004]) + expect(de.enum_type_generateur_ch_id).toBe('58'); + }); + + test('fiche "avant 1948" : chaudière bois la plus ancienne (ch, bûche 48 -> 55)', () => { + getFicheTechnique.mockReturnValue({ valeur: 'avant 1948' }); + const de = { enum_type_generateur_ch_id: '48' }; + updateGenerateurBouilleurs({}, de, 'ch'); + expect(de.enum_type_generateur_ch_id).toBe('55'); + }); + + test.each([ + ['2020', '61'], // >= 2019 + ['2018', '60'], // >= 2018 + ['2015', '59'], // >= 2013 + ['2005', '58'], // >= 2004 + ['1996', '57'], // >= 1995 + ['1980', '56'], // >= 1978 + ['1950', '55'] // >= 1948 + ])('année %s : seuil de période appliqué (ch, bûche 48 -> %s)', (annee, attendu) => { + getFicheTechnique.mockReturnValue({ valeur: annee }); + const de = { enum_type_generateur_ch_id: '48' }; + updateGenerateurBouilleurs({}, de, 'ch'); + expect(de.enum_type_generateur_ch_id).toBe(attendu); + }); + + test('type ECS (granulés 115) : utilise la table ECS des chaudières bois', () => { + getFicheTechnique.mockReturnValue({ valeur: '2016' }); + const de = { enum_type_generateur_ecs_id: '115' }; + updateGenerateurBouilleurs({}, de, 'ecs'); + // granulés ECS : 2013->33 ; 2016 >= 2013 => 33 + expect(de.enum_type_generateur_ecs_id).toBe('33'); + }); + + test("la fiche technique est interrogée pour un 'bouilleur' dans le domaine '7'", () => { + getFicheTechnique.mockReturnValue({ valeur: '2000' }); + updateGenerateurBouilleurs({}, { enum_type_generateur_ch_id: '48' }, 'ch'); + expect(getFicheTechnique).toHaveBeenCalledWith({}, '7', 'année', ['bouilleur']); + }); +}); diff --git a/src/13.2_generateur_combustion_ch.spec.js b/src/13.2_generateur_combustion_ch.spec.js new file mode 100644 index 0000000..699a9f9 --- /dev/null +++ b/src/13.2_generateur_combustion_ch.spec.js @@ -0,0 +1,189 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +/** + * Dépendances mockées pour isoler la logique de calcul : + * - `enums` (import par défaut) : mapping type de générateur / type d'énergie, qui + * sélectionne la branche de calcul du rendement et le coefficient K ; + * - `tv` : accès aux tables temp_fonc_30 / temp_fonc_100 (lignes contrôlées) ; + * - `requestInput` / `requestInputID` : passe-plats vers les données d'entrée ; + * - `bug_for_bug_compat` : désactivé pour isoler le comportement nominal. + */ +vi.mock('./enums.js', () => ({ + default: { + type_generateur_ch: { + 89: 'chaudière gaz standard 2001-2015', + 55: 'chaudière bois bûche avant 1978', + 96: 'chaudière gaz à condensation 2001-2015' + }, + type_energie: { + 2: 'gaz naturel', + 4: 'bois – bûches' + } + } +})); + +vi.mock('./utils.js', () => ({ + bug_for_bug_compat: false, + tv: vi.fn(), + requestInput: vi.fn((de, du, field) => de[field]), + requestInputID: vi.fn((de, du, field) => de[`enum_${field}_id`]) +})); + +const { tv_temp_fonc_30_100, calc_generateur_combustion_ch } = await import( + './13.2_generateur_combustion_ch.js' +); +const { tv } = await import('./utils.js'); + +beforeEach(() => { + vi.mocked(tv).mockReset(); +}); + +/** + * 13.2.1.1 - Températures de fonctionnement à 30 % et 100 % de charge. + * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §13.2.1.1 + */ +describe('tv_temp_fonc_30_100 - températures de fonctionnement', () => { + function emetteur(overrides = {}) { + return { + donnee_entree: { + enum_temp_distribution_ch_id: '3', + ...overrides + }, + donnee_utilisateur: {} + }; + } + + test('les températures forfaitaires trouvées sont recopiées dans les données intermédiaires', () => { + tv.mockImplementation((table) => + table === 'temp_fonc_30' + ? { tv_temp_fonc_30_id: '5', temp_fonc_30: '40' } + : { tv_temp_fonc_100_id: '6', temp_fonc_100: '70' } + ); + const di = {}; + const de = { enum_type_generateur_ch_id: '89' }; + tv_temp_fonc_30_100(di, de, {}, [emetteur()], 1975); + expect(di.temp_fonc_30).toBe(40); + expect(di.temp_fonc_100).toBe(70); + expect(de.tv_temp_fonc_30_id).toBe('5'); + expect(de.tv_temp_fonc_100_id).toBe('6'); + }); + + test.each([ + [1975, 'avant 1981'], + [1990, 'entre 1981 et 2000'], + [2010, 'après 2000'] + ])("période des émetteurs déduite de l'année de construction %s : %s", (ac, periodeAttendue) => { + tv.mockReturnValue({ tv_temp_fonc_30_id: '1', temp_fonc_30: '40' }); + tv_temp_fonc_30_100({}, { enum_type_generateur_ch_id: '89' }, {}, [emetteur()], ac); + expect(tv).toHaveBeenCalledWith( + 'temp_fonc_30', + expect.objectContaining({ periode_emetteurs: periodeAttendue }) + ); + }); + + test('conserve la température la plus élevée entre plusieurs émetteurs', () => { + tv.mockImplementation((table, matcher) => { + if (table !== 'temp_fonc_30') return null; + // Le premier émetteur renvoie 40, le second 55 + return matcher.enum_temp_distribution_ch_id === '3' + ? { tv_temp_fonc_30_id: 'a', temp_fonc_30: '40' } + : { tv_temp_fonc_30_id: 'b', temp_fonc_30: '55' }; + }); + const di = {}; + tv_temp_fonc_30_100( + di, + { enum_type_generateur_ch_id: '89' }, + {}, + [emetteur(), emetteur({ enum_temp_distribution_ch_id: '4' })], + 1975 + ); + expect(di.temp_fonc_30).toBe(55); + }); +}); + +/** + * 13.2.1.2 - Rendement de génération des chaudières (rg PCI). + * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §13.2.1.2 + */ +describe('calc_generateur_combustion_ch - rendement de génération', () => { + test('chaudière gaz standard : rendement PCI et rendement dépensier', () => { + const di = { pn: 25000, rpn: 0.9, rpint: 0.85, qp0: 200, temp_fonc_30: 40, temp_fonc_100: 70 }; + const de = { + enum_type_generateur_ch_id: '89', + enum_type_energie_id: '2', + type_energie: 'gaz naturel', + presence_regulation_combustion: false, + description: 't' + }; + calc_generateur_combustion_ch({}, di, de, { cdimref: 0.8, cdimrefDep: 0.6 }); + // valeurs de référence de régression (module réel, enums réels équivalents) + expect(di.rg).toBeCloseTo(0.8582190936918361, 9); + expect(di.rg_dep).toBeCloseTo(0.8706053178411902, 9); + expect(di.rendement_generation).toBeCloseTo(0.8582190936918361, 9); + }); + + test('chaudière bois : branche de calcul dédiée (QP50 / QP100)', () => { + const di = { pn: 30000, rpn: 0.75, rpint: 0.7, qp0: 300, temp_fonc_30: 40, temp_fonc_100: 70 }; + const de = { + enum_type_generateur_ch_id: '55', + enum_type_energie_id: '4', + type_energie: 'bois – bûches', + description: 't' + }; + calc_generateur_combustion_ch({}, di, de, { cdimref: 0.9, cdimrefDep: 0.7 }); + // valeurs de référence de régression + expect(di.rg).toBeCloseTo(0.7044775930164003, 9); + expect(di.rg_dep).toBeCloseTo(0.7138933157828895, 9); + }); + + test('chaudière à condensation avec régulation : température de fonctionnement à 30 % utilisée', () => { + const di = { pn: 25000, rpn: 0.95, rpint: 0.92, qp0: 150, temp_fonc_30: 35, temp_fonc_100: 70 }; + const de = { + enum_type_generateur_ch_id: '96', + enum_type_energie_id: '2', + type_energie: 'gaz naturel', + presence_regulation_combustion: true, + description: 't' + }; + calc_generateur_combustion_ch({}, di, de, { cdimref: 0.8, cdimrefDep: 0.6 }); + // valeurs de référence de régression + expect(di.rg).toBeCloseTo(0.9281152399114512, 9); + expect(di.rg_dep).toBeCloseTo(0.9344861707155592, 9); + }); + + test('la présence de régulation modifie le rendement (tf30 vs tf100)', () => { + const base = () => ({ + pn: 25000, + rpn: 0.95, + rpint: 0.92, + qp0: 150, + temp_fonc_30: 35, + temp_fonc_100: 70 + }); + const deCommun = { + enum_type_generateur_ch_id: '96', + enum_type_energie_id: '2', + type_energie: 'gaz naturel', + description: 't' + }; + const diAvec = base(); + calc_generateur_combustion_ch( + {}, + diAvec, + { ...deCommun, presence_regulation_combustion: true }, + { + cdimref: 0.8, + cdimrefDep: 0.6 + } + ); + const diSans = base(); + calc_generateur_combustion_ch( + {}, + diSans, + { ...deCommun, presence_regulation_combustion: false }, + { cdimref: 0.8, cdimrefDep: 0.6 } + ); + // Avec régulation (tf plus basse) le rendement est plus élevé + expect(diAvec.rg).toBeGreaterThan(diSans.rg); + }); +}); diff --git a/src/13.2_generateur_combustion_chaudiere.spec.js b/src/13.2_generateur_combustion_chaudiere.spec.js new file mode 100644 index 0000000..abaf024 --- /dev/null +++ b/src/13.2_generateur_combustion_chaudiere.spec.js @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +/** + * Dépendance mockée : `getFicheTechnique` (import par défaut), qui fournit l'année + * d'installation utilisée pour choisir le générateur équivalent. On contrôle sa valeur + * de retour ; son fonctionnement interne n'est pas testé ici. + */ +vi.mock('./ficheTechnique.js', () => ({ + default: vi.fn() +})); + +const { updateGenerateurChaudieres } = await import('./13.2_generateur_combustion_chaudiere.js'); +const { default: getFicheTechnique } = await import('./ficheTechnique.js'); + +beforeEach(() => { + vi.mocked(getFicheTechnique).mockReset(); +}); + +/** + * 13.2 - Les "autres systèmes à combustion" sont assimilés à des chaudières standard + * de la période correspondante. + * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §13.2 + */ +describe('updateGenerateurChaudieres - substitution par une chaudière équivalente', () => { + test('type non concerné : aucune substitution, pas de champ previous', () => { + getFicheTechnique.mockReturnValue(undefined); + const de = { enum_type_generateur_ch_id: '90' }; + updateGenerateurChaudieres({}, de, 'ch'); + expect(de.enum_type_generateur_ch_id).toBe('90'); + expect(de.previous_enum_type_generateur_ch_id).toBeUndefined(); + }); + + test('sans fiche technique : identifiant conservé mais previous renseigné (ch, gaz 113)', () => { + getFicheTechnique.mockReturnValue(undefined); + const de = { enum_type_generateur_ch_id: '113' }; + updateGenerateurChaudieres({}, de, 'ch'); + expect(de.enum_type_generateur_ch_id).toBe('113'); + expect(de.previous_enum_type_generateur_ch_id).toBe('113'); + }); + + test('fiche "avant 1948" : chaudière la plus ancienne de la table (ch, gaz 113 -> 85)', () => { + getFicheTechnique.mockReturnValue({ valeur: 'avant 1948' }); + const de = { enum_type_generateur_ch_id: '113' }; + updateGenerateurChaudieres({}, de, 'ch'); + expect(de.enum_type_generateur_ch_id).toBe('85'); + expect(de.previous_enum_type_generateur_ch_id).toBe('113'); + }); + + test('année 1985 : seuil 1981 retenu (ch, gaz 113 -> 86)', () => { + getFicheTechnique.mockReturnValue({ valeur: '1985' }); + const de = { enum_type_generateur_ch_id: '113' }; + updateGenerateurChaudieres({}, de, 'ch'); + expect(de.enum_type_generateur_ch_id).toBe('86'); + }); + + test('année postérieure au dernier seuil : chaudière la plus récente (ch, gaz 113 -> 90)', () => { + getFicheTechnique.mockReturnValue({ valeur: '2030' }); + const de = { enum_type_generateur_ch_id: '113' }; + updateGenerateurChaudieres({}, de, 'ch'); + expect(de.enum_type_generateur_ch_id).toBe('90'); + }); + + test('type ECS (fioul 79) : utilise la table ECS et le seuil correspondant', () => { + getFicheTechnique.mockReturnValue({ valeur: '1972' }); + const de = { enum_type_generateur_ecs_id: '79' }; + updateGenerateurChaudieres({}, de, 'ecs'); + // seuils fioul ECS : 1948->35, 1970->36 ... 1972 >= 1970 => 36 + expect(de.enum_type_generateur_ecs_id).toBe('36'); + expect(de.previous_enum_type_generateur_ecs_id).toBe('79'); + }); + + test("la fiche technique est interrogée avec le bon domaine ('7' pour ch, '8' pour ecs)", () => { + getFicheTechnique.mockReturnValue({ valeur: '2000' }); + updateGenerateurChaudieres({}, { enum_type_generateur_ch_id: '113' }, 'ch'); + expect(getFicheTechnique).toHaveBeenCalledWith({}, '7', 'année', [ + 'Autre système à combustion' + ]); + + getFicheTechnique.mockClear(); + updateGenerateurChaudieres({}, { enum_type_generateur_ecs_id: '79' }, 'ecs'); + expect(getFicheTechnique).toHaveBeenCalledWith({}, '8', 'année', [ + 'Autre système à combustion' + ]); + }); +}); diff --git a/src/13.2_generateur_pac.spec.js b/src/13.2_generateur_pac.spec.js new file mode 100644 index 0000000..d68cd2e --- /dev/null +++ b/src/13.2_generateur_pac.spec.js @@ -0,0 +1,64 @@ +import { describe, expect, test } from 'vitest'; + +/** + * Ce module ne dépend d'aucune table de valeurs ni d'aucun enum : la logique est + * entièrement déterminée par les données d'entrée. Aucun mock n'est donc nécessaire. + */ +const { updateGenerateurPacs } = await import('./13.2_generateur_pac.js'); + +/** + * 13.2 - Substitution des générateurs "autre système thermodynamique" par un CET + * sur air ambiant équivalent selon la période d'installation. + * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §13.2 + */ +describe('updateGenerateurPacs - substitution des générateurs thermodynamiques', () => { + test("aucune période d'installation renseignée : aucune modification", () => { + const de = { enum_type_generateur_ecs_id: '82' }; + updateGenerateurPacs({}, de, 'ecs'); + expect(de.enum_type_generateur_ecs_id).toBe('82'); + }); + + test.each([ + ['1', '10'], + ['2', '11'], + ['3', '12'] + ])( + 'ECS type 82 (autre système thermodynamique électrique), période %s : générateur remplacé par %s', + (periode, attendu) => { + const de = { + enum_type_generateur_ecs_id: '82', + enum_periode_installation_ecs_thermo_id: periode + }; + updateGenerateurPacs({}, de, 'ecs'); + expect(de.enum_type_generateur_ecs_id).toBe(attendu); + } + ); + + test('ECS type 82 avec période inconnue : type conservé tel quel', () => { + const de = { + enum_type_generateur_ecs_id: '82', + enum_periode_installation_ecs_thermo_id: '9' + }; + updateGenerateurPacs({}, de, 'ecs'); + expect(de.enum_type_generateur_ecs_id).toBe('82'); + }); + + test('ECS type différent de 82 : aucune substitution même avec une période', () => { + const de = { + enum_type_generateur_ecs_id: '50', + enum_periode_installation_ecs_thermo_id: '1' + }; + updateGenerateurPacs({}, de, 'ecs'); + expect(de.enum_type_generateur_ecs_id).toBe('50'); + }); + + test('type ch avec une période : la substitution ECS ne s’applique pas', () => { + const de = { + enum_type_generateur_ch_id: '82', + enum_periode_installation_ecs_thermo_id: '1' + }; + updateGenerateurPacs({}, de, 'ch'); + // La branche de substitution est réservée au type 'ecs' : valeur inchangée + expect(de.enum_type_generateur_ch_id).toBe('82'); + }); +}); diff --git a/src/9_emetteur_ch.spec.js b/src/9_emetteur_ch.spec.js new file mode 100644 index 0000000..194e0e6 --- /dev/null +++ b/src/9_emetteur_ch.spec.js @@ -0,0 +1,174 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +/** + * Dépendances mockées pour isoler `9_emetteur_ch.js` : + * - `tv` : accès générique aux tables (rendements d'émission / de régulation / + * intermittence). On pilote la ligne retournée selon la table interrogée ; + * - `bug_for_bug_compat` : désactivé pour rester sur le comportement nominal ; + * - `TvsStore` : store des rendements de distribution, remplacé par une classe + * factice dont on contrôle les méthodes via `vi.hoisted`. + */ +const { getRendementDistributionCh, getRendementDistributionChById } = vi.hoisted(() => ({ + getRendementDistributionCh: vi.fn(), + getRendementDistributionChById: vi.fn() +})); + +vi.mock('./utils.js', () => ({ + tv: vi.fn(), + bug_for_bug_compat: false +})); + +vi.mock('./core/tv/infrastructure/tvs.store.js', () => ({ + TvsStore: vi.fn(() => ({ + getRendementDistributionCh, + getRendementDistributionChById + })) +})); + +const { rendement_emission, calc_emetteur_ch } = await import('./9_emetteur_ch.js'); +const { tv } = await import('./utils.js'); + +beforeEach(() => { + vi.mocked(tv).mockReset(); + getRendementDistributionCh.mockReset(); + getRendementDistributionChById.mockReset(); +}); + +/** + * 9. Rendements des émetteurs de chauffage + * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §9 + */ +describe('rendement_emission - produit des rendements', () => { + const emetteur = { + donnee_intermediaire: { + rendement_emission: 0.95, + rendement_distribution: 0.9, + rendement_regulation: 0.9 + } + }; + + test('rendement global = rg * re * rd * rr (rg = 1 par défaut)', () => { + expect(rendement_emission(emetteur)).toBeCloseTo(0.7695, 12); + }); + + test('le rendement de génération (rg) est appliqué en facteur', () => { + expect(rendement_emission(emetteur, 0.8)).toBeCloseTo(0.6156, 12); + }); +}); + +describe('calc_emetteur_ch - renseignement des données intermédiaires', () => { + /** Pilote la fonction `tv` selon la table interrogée. */ + function stubTv() { + vi.mocked(tv).mockImplementation((table) => { + if (table === 'rendement_emission') { + return { re: '0.95', tv_rendement_emission_id: '11' }; + } + if (table === 'rendement_regulation') { + return { rr: '0.99', tv_rendement_regulation_id: '22' }; + } + if (table === 'intermittence') { + return { i0: '0.85', tv_intermittence_id: '33' }; + } + return null; + }); + } + + test("agrège les rendements (distribution, émission, régulation) et l'intermittence", () => { + stubTv(); + getRendementDistributionCh.mockReturnValue({ + rd: '0.92', + tv_rendement_distribution_ch_id: '44' + }); + + const em_ch = { + donnee_entree: { + enum_type_emission_distribution_id: '10', + reseau_distribution_isole: 1 + } + }; + calc_emetteur_ch(em_ch, { enum_type_installation_id: '1' }, '2', '3'); + + expect(em_ch.donnee_intermediaire).toEqual({ + rendement_distribution: 0.92, + rendement_emission: 0.95, + rendement_regulation: 0.99, + i0: 0.85 + }); + // les identifiants de table sont réécrits en nombres sur la donnée d'entrée + expect(em_ch.donnee_entree.tv_rendement_distribution_ch_id).toBe(44); + expect(em_ch.donnee_entree.tv_rendement_emission_id).toBe(11); + expect(em_ch.donnee_entree.tv_rendement_regulation_id).toBe(22); + expect(em_ch.donnee_entree.tv_intermittence_id).toBe(33); + expect(em_ch.donnee_utilisateur).toEqual({}); + }); + + test('rendement de distribution : repli sur la recherche par identifiant si aucune ligne directe', () => { + stubTv(); + getRendementDistributionCh.mockReturnValue(undefined); + getRendementDistributionChById.mockReturnValue({ + rd: '0.88', + tv_rendement_distribution_ch_id: '55' + }); + + const em_ch = { + donnee_entree: { + enum_type_emission_distribution_id: '10', + tv_rendement_distribution_ch_id: 55 + } + }; + calc_emetteur_ch(em_ch, { enum_type_installation_id: '1' }, '2', '3'); + + expect(getRendementDistributionChById).toHaveBeenCalledWith(55); + expect(em_ch.donnee_intermediaire.rendement_distribution).toBe(0.88); + }); + + test("intermittence : la fiche technique de comptage force la présence d'un comptage individuel", () => { + stubTv(); + getRendementDistributionCh.mockReturnValue({ + rd: '0.92', + tv_rendement_distribution_ch_id: '44' + }); + + const em_ch = { + donnee_entree: { + enum_type_emission_distribution_id: '10', + enum_type_chauffage_id: '1', + enum_equipement_intermittence_id: '1', + enum_type_regulation_id: '1' + } + }; + // map_id === '1' => la classe d'inertie entre dans le matcher + calc_emetteur_ch( + em_ch, + { + enum_type_installation_id: '1', + ficheTechniqueComptage: { valeur: '1' } + }, + '1', + '7' + ); + + const matcher = vi.mocked(tv).mock.calls.find((c) => c[0] === 'intermittence')[1]; + expect(matcher.comptage_individuel).toBe('Présence'); + expect(matcher.enum_methode_application_dpe_log_id).toBe('1'); + expect(matcher.enum_classe_inertie_id).toBe('7'); + }); + + test('intermittence : absence de comptage individuel hors fiche technique', () => { + stubTv(); + getRendementDistributionCh.mockReturnValue({ + rd: '0.92', + tv_rendement_distribution_ch_id: '44' + }); + + const em_ch = { + donnee_entree: { enum_type_emission_distribution_id: '10' } + }; + // map_id !== '1' => pas de classe d'inertie dans le matcher + calc_emetteur_ch(em_ch, { enum_type_installation_id: '1' }, '2', '3'); + + const matcher = vi.mocked(tv).mock.calls.find((c) => c[0] === 'intermittence')[1]; + expect(matcher.comptage_individuel).toBe('Absence'); + expect(matcher.enum_classe_inertie_id).toBeUndefined(); + }); +}); diff --git a/src/features/dpe/domain/models/type-generateur.model.spec.js b/src/features/dpe/domain/models/type-generateur.model.spec.js new file mode 100644 index 0000000..77a1510 --- /dev/null +++ b/src/features/dpe/domain/models/type-generateur.model.spec.js @@ -0,0 +1,26 @@ +import { describe, expect, test } from 'vitest'; +import { TypeGenerateur } from './type-generateur.model.js'; + +describe('Modèle TypeGenerateur', () => { + test('expose les types de générateurs attendus', () => { + expect(TypeGenerateur).toEqual({ + COMBUSTION: 'COMBUSTION', + PAC: 'PAC', + OTHER: 'OTHER' + }); + }); + + test('chaque clé correspond à une valeur chaîne identique (auto-référence)', () => { + Object.entries(TypeGenerateur).forEach(([cle, valeur]) => { + expect(typeof valeur).toBe('string'); + expect(valeur).toBe(cle); + }); + }); + + test('les valeurs sont uniques et non vides', () => { + const valeurs = Object.values(TypeGenerateur); + expect(valeurs.length).toBeGreaterThan(0); + expect(new Set(valeurs).size).toBe(valeurs.length); + valeurs.forEach((valeur) => expect(valeur.length).toBeGreaterThan(0)); + }); +}); From 273d16f8886606db3c8b25c9c058e99c9fd393d1 Mon Sep 17 00:00:00 2001 From: Florent Perinel Date: Mon, 13 Jul 2026 17:02:26 +0200 Subject: [PATCH 17/20] test: add unit test for ecs --- src/11_besoin_ecs.spec.js | 69 +++++++++ src/11_ecs.spec.js | 181 ++++++++++++++++++++++ src/13_rendement_distribution_ecs.spec.js | 38 +++++ 3 files changed, 288 insertions(+) create mode 100644 src/11_besoin_ecs.spec.js create mode 100644 src/11_ecs.spec.js create mode 100644 src/13_rendement_distribution_ecs.spec.js diff --git a/src/11_besoin_ecs.spec.js b/src/11_besoin_ecs.spec.js new file mode 100644 index 0000000..73e1aad --- /dev/null +++ b/src/11_besoin_ecs.spec.js @@ -0,0 +1,69 @@ +import { describe, expect, test, vi } from 'vitest'; + +/** + * Dépendances mockées pour isoler le module : + * - `tvs.tefs` : température d'eau froide sanitaire mensuelle par classe d'altitude / zone ; + * - `Njj` : nombre de jours du mois ; + * - `mois_liste` : réduite à un seul mois pour rendre la somme annuelle vérifiable. + * Le calcul du besoin ECS ne dépend ainsi que de valeurs contrôlées. + */ +vi.mock('./tv.js', () => ({ + tvs: { + tefs: { ca1: { Janvier: { h1a: 10 } } } + } +})); + +vi.mock('./utils.js', () => ({ + mois_liste: ['Janvier'], + Njj: { Janvier: 30 } +})); + +const { default: calc_besoin_ecs, calc_besoin_ecs_j } = await import('./11_besoin_ecs.js'); + +/** + * 11. Calcul du besoin d'eau chaude sanitaire (Becs) + * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §11 + */ +describe('calc_besoin_ecs_j - besoin ECS journalier', () => { + test('valeur de référence en mode conventionnel (56 L/j/personne)', () => { + // valeur de référence de régression : (1.163 * 2 * 56 * (40 - 10) * 30) / 1000 + expect(calc_besoin_ecs_j('ca1', 'Janvier', 'h1a', 2, false)).toBeCloseTo(117.2304, 9); + }); + + test('valeur de référence en mode dépensier (79 L/j/personne)', () => { + // valeur de référence de régression : (1.163 * 2 * 79 * (40 - 10) * 30) / 1000 + expect(calc_besoin_ecs_j('ca1', 'Janvier', 'h1a', 2, true)).toBeCloseTo(165.3786, 9); + }); + + test('le besoin dépensier est supérieur au besoin conventionnel (ratio 79/56)', () => { + const conventionnel = calc_besoin_ecs_j('ca1', 'Janvier', 'h1a', 2, false); + const depensier = calc_besoin_ecs_j('ca1', 'Janvier', 'h1a', 2, true); + expect(depensier / conventionnel).toBeCloseTo(79 / 56, 9); + }); + + test('un nombre équivalent d’adultes nul donne un besoin nul', () => { + expect(calc_besoin_ecs_j('ca1', 'Janvier', 'h1a', 0, false)).toBe(0); + expect(calc_besoin_ecs_j('ca1', 'Janvier', 'h1a', 0, true)).toBe(0); + }); + + test('le besoin est proportionnel au nombre équivalent d’adultes', () => { + const un = calc_besoin_ecs_j('ca1', 'Janvier', 'h1a', 1, false); + const trois = calc_besoin_ecs_j('ca1', 'Janvier', 'h1a', 3, false); + expect(trois).toBeCloseTo(3 * un, 9); + }); +}); + +describe('calc_besoin_ecs - agrégation annuelle', () => { + test('agrège les besoins conventionnel et dépensier sur les mois', () => { + const ret = calc_besoin_ecs('ca1', 'h1a', 2); + // un seul mois mocké => identique au calcul journalier + expect(ret.besoin_ecs).toBeCloseTo(117.2304, 9); + expect(ret.besoin_ecs_depensier).toBeCloseTo(165.3786, 9); + }); + + test('un nombre équivalent d’adultes nul donne un besoin annuel nul', () => { + const ret = calc_besoin_ecs('ca1', 'h1a', 0); + expect(ret.besoin_ecs).toBe(0); + expect(ret.besoin_ecs_depensier).toBe(0); + }); +}); diff --git a/src/11_ecs.spec.js b/src/11_ecs.spec.js new file mode 100644 index 0000000..7787674 --- /dev/null +++ b/src/11_ecs.spec.js @@ -0,0 +1,181 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +/** + * Dépendances mockées pour isoler `calc_ecs` : + * - `enums.type_installation` : libellé du type d'installation (individuelle / collective) ; + * - `tv` : accès à la table rendement_distribution_ecs (ligne contrôlée) ; + * - `requestInput` : passe-plat vers les données d'entrée pour le réseau collectif ; + * - `calc_gen_ecs` : calcul par générateur, neutralisé (les consommations sont pré-remplies). + * Aucune vraie table de valeurs n'est sollicitée : les tests restent stables si les données changent. + */ +vi.mock('./enums.js', () => ({ + default: { + type_installation: { + 1: 'installation individuelle', + 2: 'installation collective', + 3: 'installation collective multi-bâtiment' + } + } +})); + +vi.mock('./utils.js', () => ({ + tv: vi.fn(), + requestInput: vi.fn() +})); + +vi.mock('./14_generateur_ecs.js', () => ({ + default: vi.fn() +})); + +const { default: calc_ecs } = await import('./11_ecs.js'); +const { tv, requestInput } = await import('./utils.js'); +const { default: calc_gen_ecs } = await import('./14_generateur_ecs.js'); + +/** Fabrique une installation ECS avec un générateur dont la consommation est pré-remplie. */ +function makeEcs(de, gens) { + return { + donnee_entree: de, + generateur_ecs_collection: { + generateur_ecs: gens ?? [ + { + donnee_entree: { position_volume_chauffe: 1 }, + donnee_intermediaire: { conso_ecs: 10, conso_ecs_depensier: 20 } + } + ] + } + }; +} + +beforeEach(() => { + vi.mocked(tv).mockReset(); + vi.mocked(requestInput).mockReset(); + vi.mocked(calc_gen_ecs).mockReset(); + // ligne forfaitaire de rendement de distribution par défaut + vi.mocked(tv).mockReturnValue({ rd: '0.9', tv_rendement_distribution_ecs_id: '5' }); +}); + +/** + * 11. Prise en compte de l'ECS (ratio de besoin + rendement de distribution) + * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §11 / §17.2.1 + */ +describe('calc_ecs - ratio de besoin ECS', () => { + test('virtualisation : le ratio est la clé de répartition ECS', () => { + const de = { tv_rendement_distribution_ecs_id: '5', cle_repartition_ecs: 0.5 }; + const ecs = makeEcs(de); + calc_ecs({}, ecs, 100, 200, 1, 'ca1', 'h1a', 'maison', true, null, null, false); + + expect(ecs.donnee_intermediaire.ratio_besoin_ecs).toBe(0.5); + expect(ecs.donnee_intermediaire.besoin_ecs).toBeCloseTo(50, 9); + expect(ecs.donnee_intermediaire.besoin_ecs_depensier).toBeCloseTo(100, 9); + }); + + test('immeuble à systèmes ECS individuels : ratio = 1 / nombre d’appartements', () => { + const de = { tv_rendement_distribution_ecs_id: '5' }; + const ecs = makeEcs(de); + calc_ecs({}, ecs, 100, 200, 1, 'ca1', 'h1a', 'immeuble', false, null, 4, true); + + expect(ecs.donnee_intermediaire.ratio_besoin_ecs).toBe(0.25); + expect(ecs.donnee_intermediaire.besoin_ecs).toBeCloseTo(25, 9); + }); + + test('présence de rdim : ratio = 1 / rdim', () => { + const de = { tv_rendement_distribution_ecs_id: '5', rdim: 2 }; + const ecs = makeEcs(de); + calc_ecs({}, ecs, 100, 200, 1, 'ca1', 'h1a', 'maison', false, null, null, false); + + expect(ecs.donnee_intermediaire.ratio_besoin_ecs).toBe(0.5); + expect(ecs.donnee_intermediaire.besoin_ecs).toBeCloseTo(50, 9); + }); + + test('cas par défaut : ratio = 1 (aucune proratisation)', () => { + const de = { tv_rendement_distribution_ecs_id: '5' }; + const ecs = makeEcs(de); + calc_ecs({}, ecs, 100, 200, 1, 'ca1', 'h1a', 'maison', false, null, null, false); + + expect(ecs.donnee_intermediaire.ratio_besoin_ecs).toBe(1); + expect(ecs.donnee_intermediaire.besoin_ecs).toBe(100); + expect(ecs.donnee_intermediaire.besoin_ecs_depensier).toBe(200); + }); +}); + +describe('calc_ecs - rendement de distribution', () => { + test('la ligne forfaitaire alimente rendement_distribution et l’identifiant de table', () => { + const de = { tv_rendement_distribution_ecs_id: '5' }; + const ecs = makeEcs(de); + calc_ecs({}, ecs, 100, 200, 1, 'ca1', 'h1a', 'maison', false, null, null, false); + + expect(ecs.donnee_intermediaire.rendement_distribution).toBe(0.9); + expect(de.tv_rendement_distribution_ecs_id).toBe(5); + }); + + test('installation individuelle, production dans le volume chauffé : matcher de configuration', () => { + const de = { enum_type_installation_id: '1' }; + const ecs = makeEcs(de, [ + { + donnee_entree: { position_volume_chauffe: 1 }, + donnee_intermediaire: { conso_ecs: 0, conso_ecs_depensier: 0 } + } + ]); + calc_ecs({}, ecs, 100, 200, 1, 'ca1', 'h1a', 'maison', false, null, null, false); + + expect(tv).toHaveBeenCalledWith('rendement_distribution_ecs', { + configuration_logement: 'production volume habitable [+] pièces alimentées contiguës' + }); + }); + + test('installation individuelle, production hors volume chauffé : configuration adaptée', () => { + const de = { enum_type_installation_id: '1' }; + const ecs = makeEcs(de, [ + { + donnee_entree: { position_volume_chauffe: 0 }, + donnee_intermediaire: { conso_ecs: 0, conso_ecs_depensier: 0 } + } + ]); + calc_ecs({}, ecs, 100, 200, 1, 'ca1', 'h1a', 'maison', false, null, null, false); + + expect(tv).toHaveBeenCalledWith('rendement_distribution_ecs', { + configuration_logement: 'production hors volume habitable' + }); + }); + + test('installation collective non isolée : matcher type de réseau + configuration', () => { + vi.mocked(requestInput).mockImplementation((de, du, field) => + field === 'reseau_distribution_isole' ? 0 : undefined + ); + const de = { enum_type_installation_id: '2' }; + const ecs = makeEcs(de, [ + { + donnee_entree: { position_volume_chauffe: 1 }, + donnee_intermediaire: { conso_ecs: 0, conso_ecs_depensier: 0 } + } + ]); + calc_ecs({}, ecs, 100, 200, 1, 'ca1', 'h1a', 'immeuble', false, null, null, false); + + expect(tv).toHaveBeenCalledWith('rendement_distribution_ecs', { + type_reseau_collectif: 'Réseau collectif non isolé', + configuration_logement: 'majorité des logements avec pièces alimentées contiguës' + }); + }); +}); + +describe('calc_ecs - agrégation des consommations', () => { + test('somme les consommations des générateurs et invoque calc_gen_ecs par générateur', () => { + const de = { tv_rendement_distribution_ecs_id: '5' }; + const gens = [ + { + donnee_entree: { position_volume_chauffe: 1 }, + donnee_intermediaire: { conso_ecs: 10, conso_ecs_depensier: 20 } + }, + { + donnee_entree: { position_volume_chauffe: 1 }, + donnee_intermediaire: { conso_ecs: 5, conso_ecs_depensier: 8 } + } + ]; + const ecs = makeEcs(de, gens); + calc_ecs({}, ecs, 100, 200, 1, 'ca1', 'h1a', 'maison', false, null, null, false); + + expect(calc_gen_ecs).toHaveBeenCalledTimes(2); + expect(ecs.donnee_intermediaire.conso_ecs).toBe(15); + expect(ecs.donnee_intermediaire.conso_ecs_depensier).toBe(28); + }); +}); diff --git a/src/13_rendement_distribution_ecs.spec.js b/src/13_rendement_distribution_ecs.spec.js new file mode 100644 index 0000000..ae5e8e0 --- /dev/null +++ b/src/13_rendement_distribution_ecs.spec.js @@ -0,0 +1,38 @@ +import { describe, expect, test, vi } from 'vitest'; + +/** + * Dépendances mockées pour isoler le module : + * - `enums.type_installation` : libellé du type d'installation dérivé de l'identifiant d'entrée. + * La fonction actuelle se contente de résoudre le type d'installation sans modifier l'objet ECS ; + * ces tests figent ce contrat (aucune mutation, retour indéfini) pour détecter toute régression. + */ +vi.mock('./enums.js', () => ({ + default: { + type_installation: { 1: 'installation individuelle', 2: 'installation collective' } + } +})); + +const { default: rendement_distribution_ecs } = await import('./13_rendement_distribution_ecs.js'); + +/** + * 13. Rendement de distribution de l'ECS + * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §13 + */ +describe('rendement_distribution_ecs', () => { + test('ne retourne aucune valeur (fonction sans effet de sortie)', () => { + const ecs = { donnee_entree: { enum_type_installation_id: '1' } }; + expect(rendement_distribution_ecs(ecs)).toBeUndefined(); + }); + + test('ne mute pas les données d’entrée de l’installation ECS', () => { + const de = { enum_type_installation_id: '2' }; + const ecs = { donnee_entree: de }; + rendement_distribution_ecs(ecs); + expect(ecs.donnee_entree).toEqual({ enum_type_installation_id: '2' }); + }); + + test('accepte un type d’installation inconnu sans lever d’erreur', () => { + const ecs = { donnee_entree: { enum_type_installation_id: '999' } }; + expect(() => rendement_distribution_ecs(ecs)).not.toThrow(); + }); +}); From 5b0b2efb945bde05b6b46d8052203a2b15e2cfe2 Mon Sep 17 00:00:00 2001 From: Florent Perinel Date: Mon, 13 Jul 2026 17:07:06 +0200 Subject: [PATCH 18/20] test: add unit tests --- src/10_clim.spec.js | 116 +++++++++++ src/2021_04_13_confort_ete.spec.js | 159 ++++++++++++++++ src/apport_et_besoin.spec.js | 126 ++++++++++++ .../add-additionnal-ue-values-tables.spec.js | 53 ++++++ .../infrastructure/application.config.spec.js | 35 ++++ .../assets/additional-ue-values.spec.js | 36 ++++ src/core/util/logger/log-service.spec.js | 67 +++++++ src/dpe-sanitizer.service.spec.js | 138 ++++++++++++++ src/engine.spec.js | 180 ++++++++++++++++++ src/enums.spec.js | 46 +++++ .../models/type-habitation.model.spec.js | 41 ++++ .../domain/models/type-stockage.model.spec.js | 18 ++ .../models/type-ventilation.model.spec.js | 19 ++ src/features/engine/domain/constants.spec.js | 14 ++ .../enveloppe/deperdition.service.spec.js | 141 ++++++++++++++ src/index.spec.js | 57 ++++++ src/output.spec.js | 17 ++ src/tv.spec.js | 38 ++++ 18 files changed, 1301 insertions(+) create mode 100644 src/10_clim.spec.js create mode 100644 src/2021_04_13_confort_ete.spec.js create mode 100644 src/apport_et_besoin.spec.js create mode 100644 src/core/assets/domain/add-additionnal-ue-values-tables.spec.js create mode 100644 src/core/conf/infrastructure/application.config.spec.js create mode 100644 src/core/tv/infrastructure/assets/additional-ue-values.spec.js create mode 100644 src/core/util/logger/log-service.spec.js create mode 100644 src/dpe-sanitizer.service.spec.js create mode 100644 src/engine.spec.js create mode 100644 src/enums.spec.js create mode 100644 src/features/dpe/domain/models/type-habitation.model.spec.js create mode 100644 src/features/dpe/domain/models/type-stockage.model.spec.js create mode 100644 src/features/dpe/domain/models/type-ventilation.model.spec.js create mode 100644 src/features/engine/domain/constants.spec.js create mode 100644 src/features/engine/domain/enveloppe/deperdition.service.spec.js create mode 100644 src/index.spec.js create mode 100644 src/output.spec.js create mode 100644 src/tv.spec.js diff --git a/src/10_clim.spec.js b/src/10_clim.spec.js new file mode 100644 index 0000000..bbcea43 --- /dev/null +++ b/src/10_clim.spec.js @@ -0,0 +1,116 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +/** + * Dépendances mockées pour isoler `calc_clim` : + * - `tv` : accès à la table de valeurs `seer` (on contrôle la ligne retournée + * et donc l'EER forfaitaire) ; + * - `requestInputID` : passe-plat vers les données d'entrée pour la période + * d'installation. + */ +vi.mock('./utils.js', () => ({ + tv: vi.fn(), + requestInputID: (de, du, field) => { + const enumName = `enum_${field}_id`; + du[enumName] = de[enumName]; + return de[enumName]; + } +})); + +const { default: calc_clim } = await import('./10_clim.js'); +const { tv } = await import('./utils.js'); + +/** Fabrique un objet climatisation minimal. */ +function clim(de) { + return { donnee_entree: de }; +} + +beforeEach(() => { + vi.mocked(tv).mockReset(); +}); + +/** + * 10. Calcul de la consommation de climatisation (froid) + * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §10 + */ +describe('calc_clim - consommation de climatisation', () => { + test('prorate le besoin de froid au ratio surface_clim / Sh', () => { + tv.mockReturnValue({ tv_seer_id: '5', eer: 3 }); + const c = clim({ surface_clim: 50, enum_methode_saisie_carac_sys_id: '1' }); + + calc_clim(c, 1000, 1500, '1', 100); + + // rs = 50 / 100 = 0.5 + expect(c.donnee_intermediaire.besoin_fr).toBeCloseTo(500, 9); + expect(c.donnee_intermediaire.besoin_fr_depensier).toBeCloseTo(750, 9); + }); + + test('EER forfaitaire issu de la table : conso = 0.9 * besoin_fr / eer', () => { + tv.mockReturnValue({ tv_seer_id: '5', eer: 3 }); + const c = clim({ surface_clim: 50, enum_methode_saisie_carac_sys_id: '1' }); + + calc_clim(c, 1000, 1500, '1', 100); + + // valeur de référence de régression : 0.9 * 500 / 3 + expect(c.donnee_intermediaire.conso_fr).toBeCloseTo(150, 9); + // 0.9 * 750 / 3 + expect(c.donnee_intermediaire.conso_fr_depensier).toBeCloseTo(225, 9); + expect(c.donnee_entree.tv_seer_id).toBe(5); + expect(c.donnee_intermediaire.eer).toBe(3); + }); + + test("matcher de la table seer : zone climatique + période d'installation", () => { + tv.mockReturnValue({ tv_seer_id: '5', eer: 3 }); + const c = clim({ + surface_clim: 50, + enum_methode_saisie_carac_sys_id: '1', + enum_periode_installation_fr_id: '4' + }); + + calc_clim(c, 1000, 1500, '2', 100); + + expect(tv).toHaveBeenCalledWith( + 'seer', + { + enum_zone_climatique_id: '2', + enum_periode_installation_fr_id: '4' + }, + c.donnee_entree + ); + }); + + test.each([['6'], ['7'], ['8']])( + "méthode de saisie %s avec EER déjà connu : pas d'accès à la table forfaitaire", + (methode) => { + const c = { + donnee_entree: { surface_clim: 50, enum_methode_saisie_carac_sys_id: methode }, + donnee_intermediaire: { eer: 4 } + }; + + calc_clim(c, 1000, 1500, '1', 100); + + expect(tv).not.toHaveBeenCalled(); + // conso calculée avec l'EER saisi : 0.9 * 500 / 4 + expect(c.donnee_intermediaire.conso_fr).toBeCloseTo(112.5, 9); + } + ); + + test('méthode de saisie caractéristiques (6) mais EER absent : recours à la table', () => { + tv.mockReturnValue({ tv_seer_id: '5', eer: 3 }); + const c = clim({ surface_clim: 50, enum_methode_saisie_carac_sys_id: '6' }); + + calc_clim(c, 1000, 1500, '1', 100); + + expect(tv).toHaveBeenCalled(); + expect(c.donnee_intermediaire.eer).toBe(3); + }); + + test('aucune ligne forfaitaire trouvée : EER indéfini, conso non finie', () => { + tv.mockReturnValue(null); + const c = clim({ surface_clim: 50, enum_methode_saisie_carac_sys_id: '1' }); + + calc_clim(c, 1000, 1500, '1', 100); + + expect(c.donnee_intermediaire.eer).toBeUndefined(); + expect(Number.isFinite(c.donnee_intermediaire.conso_fr)).toBe(false); + }); +}); diff --git a/src/2021_04_13_confort_ete.spec.js b/src/2021_04_13_confort_ete.spec.js new file mode 100644 index 0000000..8f1313e --- /dev/null +++ b/src/2021_04_13_confort_ete.spec.js @@ -0,0 +1,159 @@ +import { describe, expect, test, vi } from 'vitest'; + +/** + * Dépendances mockées pour isoler `calc_confort_ete` : + * - `enums.classe_inertie` : détermine le caractère « lourd » de l'inertie ; + * - `getKeyByValue` : réduit à un passe-plat renvoyant directement le libellé + * de confort (on assertera donc `enum_indicateur_confort_ete_id` = libellé) ; + * - `requestInput` : passe-plat lisant le champ demandé dans les données d'entrée. + */ +vi.mock('./enums.js', () => ({ + default: { + classe_inertie: { 1: 'légère', 2: 'moyenne', 3: 'lourde', 4: 'très lourde' }, + indicateur_confort_ete: { 1: 'insuffisant', 2: 'moyen', 3: 'bon' } + } +})); + +vi.mock('./utils.js', () => ({ + getKeyByValue: (object, value) => value, + requestInput: (de, du, field) => de[field] +})); + +const { default: calc_confort_ete } = await import('./2021_04_13_confort_ete.js'); + +/** Fabrique une baie vitrée : `enum_orientation_id` sert au caractère traversant, + * `orientation` et `type_fermeture` à la protection solaire. */ +function baie({ orientationId, orientation = 'sud', typeFermeture = 'volet' }) { + return { + donnee_entree: { + enum_orientation_id: orientationId, + orientation, + type_fermeture: typeFermeture + }, + donnee_utilisateur: {} + }; +} + +/** Fabrique un plancher haut avec son type d'adjacence et d'isolation. */ +function plancherHaut({ typeAdjacence = 'extérieur', typeIsolation = 'isolé' }) { + return { + donnee_entree: { type_adjacence: typeAdjacence, type_isolation: typeIsolation }, + donnee_utilisateur: {} + }; +} + +/** + * Indicateur de confort d'été + * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - annexe confort d'été + */ +describe("calc_confort_ete - indicateur de confort d'été", () => { + test('inertie lourde + aspect traversant : niveau « bon »', () => { + // deux orientations distinctes => aspect_traversant = 1 ; inertie lourde = 1 => nv_bon = 2 + const bv = [baie({ orientationId: '1' }), baie({ orientationId: '2' })]; + const ph = [plancherHaut({ typeIsolation: 'isolé' })]; + + const ret = calc_confort_ete('3', bv, ph); + + expect(ret.inertie_lourde).toBe(1); + expect(ret.aspect_traversant).toBe(1); + expect(ret.enum_indicateur_confort_ete_id).toBe('bon'); + }); + + test('inertie « très lourde » également considérée comme lourde', () => { + const bv = [baie({ orientationId: '1' }), baie({ orientationId: '2' })]; + const ph = [plancherHaut({})]; + + const ret = calc_confort_ete('4', bv, ph); + + expect(ret.inertie_lourde).toBe(1); + }); + + test('une seule orientation : aspect non traversant', () => { + const bv = [baie({ orientationId: '1' }), baie({ orientationId: '1' })]; + const ph = [plancherHaut({})]; + + const ret = calc_confort_ete('2', bv, ph); + + expect(ret.aspect_traversant).toBe(0); + }); + + test('inertie légère sans aspect traversant : niveau « moyen »', () => { + // nv_bon = 0 (< 2) mais protections OK => moyen + const bv = [baie({ orientationId: '1' })]; + const ph = [plancherHaut({})]; + + const ret = calc_confort_ete('1', bv, ph); + + expect(ret.inertie_lourde).toBe(0); + expect(ret.enum_indicateur_confort_ete_id).toBe('moyen'); + }); + + test("toiture non isolée donnant sur l'extérieur : niveau « insuffisant »", () => { + const bv = [baie({ orientationId: '1' }), baie({ orientationId: '2' })]; + const ph = [plancherHaut({ typeAdjacence: 'extérieur', typeIsolation: 'non isolé' })]; + + const ret = calc_confort_ete('3', bv, ph); + + expect(ret.isolation_toiture).toBe(0); + expect(ret.enum_indicateur_confort_ete_id).toBe('insuffisant'); + }); + + test("toiture d'isolation inconnue donnant sur l'extérieur : isolation nulle", () => { + const bv = [baie({ orientationId: '1' })]; + const ph = [plancherHaut({ typeAdjacence: 'extérieur', typeIsolation: 'inconnu' })]; + + const ret = calc_confort_ete('3', bv, ph); + + expect(ret.isolation_toiture).toBe(0); + }); + + test('toiture non isolée mais non extérieure : isolation préservée', () => { + const bv = [baie({ orientationId: '1' }), baie({ orientationId: '2' })]; + const ph = [plancherHaut({ typeAdjacence: 'local non chauffé', typeIsolation: 'non isolé' })]; + + const ret = calc_confort_ete('3', bv, ph); + + expect(ret.isolation_toiture).toBe(1); + }); + + test('baie non nord sans fermeture : protection solaire nulle => « insuffisant »', () => { + const bv = [ + baie({ + orientationId: '1', + orientation: 'sud', + typeFermeture: 'abscence de fermeture pour la baie vitrée' + }) + ]; + const ph = [plancherHaut({})]; + + const ret = calc_confort_ete('3', bv, ph); + + expect(ret.protection_solaire_exterieure).toBe(0); + expect(ret.enum_indicateur_confort_ete_id).toBe('insuffisant'); + }); + + test('baie nord sans fermeture : ignorée, protection solaire préservée', () => { + const bv = [ + baie({ + orientationId: '1', + orientation: 'nord', + typeFermeture: 'abscence de fermeture pour la baie vitrée' + }), + baie({ + orientationId: '2', + orientation: 'nord', + typeFermeture: 'abscence de fermeture pour la baie vitrée' + }) + ]; + const ph = [plancherHaut({})]; + + const ret = calc_confort_ete('3', bv, ph); + + expect(ret.protection_solaire_exterieure).toBe(1); + }); + + test("le brasseur d'air est toujours absent (0) dans ce calcul", () => { + const ret = calc_confort_ete('3', [baie({ orientationId: '1' })], [plancherHaut({})]); + expect(ret.brasseur_air).toBe(0); + }); +}); diff --git a/src/apport_et_besoin.spec.js b/src/apport_et_besoin.spec.js new file mode 100644 index 0000000..841bd58 --- /dev/null +++ b/src/apport_et_besoin.spec.js @@ -0,0 +1,126 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +/** + * Dépendances mockées pour isoler `apport_et_besoin.js` (pur assembleur) : + * - `enums` : mapping zone/altitude/inertie ; + * - besoins ECS / froid, apports internes / solaires, surface sud équivalente : + * simples délégations dont on contrôle le retour ; + * - `Nadeq` : service de calcul du nombre d'adultes équivalents, remplacé par + * une classe factice via `vi.hoisted`. + */ +const { calculateNadeq } = vi.hoisted(() => ({ calculateNadeq: vi.fn() })); + +vi.mock('./enums.js', () => ({ + default: { + zone_climatique: { 0: 'h1a' }, + classe_altitude: { 0: 'ca1' }, + classe_inertie: { 0: 'moyenne' } + } +})); + +vi.mock('./6.2_surface_sud_equivalente.js', () => ({ calc_sse: vi.fn() })); +vi.mock('./10_besoin_fr.js', () => ({ default: vi.fn() })); +vi.mock('./11_besoin_ecs.js', () => ({ default: vi.fn() })); +vi.mock('./6.1_apport_gratuit.js', () => ({ calc_ai: vi.fn(), calc_as: vi.fn() })); +vi.mock('./11_nadeq.js', () => ({ Nadeq: vi.fn(() => ({ calculateNadeq })) })); + +const { default: calc_apport_et_besoin } = await import('./apport_et_besoin.js'); +const { calc_sse } = await import('./6.2_surface_sud_equivalente.js'); +const { default: calc_besoin_fr } = await import('./10_besoin_fr.js'); +const { default: calc_besoin_ecs } = await import('./11_besoin_ecs.js'); +const { calc_ai, calc_as } = await import('./6.1_apport_gratuit.js'); + +/** Logement minimal contenant les collections lues par l'assembleur. */ +function logementFixture() { + return { + enveloppe: { + inertie: { enum_classe_inertie_id: 0 }, + baie_vitree_collection: { baie_vitree: ['bv'] }, + ets_collection: { ets: ['ets'] } + } + }; +} + +beforeEach(() => { + calculateNadeq.mockReset().mockReturnValue(2.5); + vi.mocked(calc_sse).mockReset().mockReturnValue(7); + vi.mocked(calc_besoin_ecs).mockReset().mockReturnValue({ besoin_ecs: 1 }); + vi.mocked(calc_besoin_fr) + .mockReset() + .mockImplementation(() => ({ besoin_fr: 5, besoin_fr_depensier: 6 })); + vi.mocked(calc_ai) + .mockReset() + .mockImplementation(() => ({ apport_interne_ch: 10, apport_interne_fr: 20 })); + vi.mocked(calc_as) + .mockReset() + .mockImplementation(() => ({ apport_solaire_ch: 30, apport_solaire_fr: 40 })); +}); + +/** + * Calcul des apports et besoins (assembleur chauffage / froid / ECS) + * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf + */ +describe('calc_apport_et_besoin', () => { + test('agrège les besoins, apports et volumes ECS (climatisation présente)', () => { + const ret = calc_apport_et_besoin( + logementFixture(), + 'maison', + [], + ['clim'], + 100, + 1, + 250, + 0, + 0, + 0 + ); + + expect(ret.nadeq).toBe(2.5); + // volumes d'ECS journaliers : 56 L et 79 L (dépensier) par adulte équivalent + expect(ret.v40_ecs_journalier).toBe(140); + expect(ret.v40_ecs_journalier_depensier).toBe(197.5); + expect(ret.surface_sud_equivalente).toBe(7); + // fusion des retours délégués + expect(ret.besoin_ecs).toBe(1); + expect(ret.besoin_fr).toBe(5); + expect(ret.besoin_fr_depensier).toBe(6); + expect(ret.apport_interne_fr).toBe(20); + expect(ret.apport_solaire_fr).toBe(40); + expect(ret.apport_interne_ch).toBe(10); + expect(ret.apport_solaire_ch).toBe(30); + }); + + test('délègue avec les bons libellés (altitude/zone/inertie) et collections', () => { + const logement = logementFixture(); + calc_apport_et_besoin(logement, 'maison', [], ['clim'], 100, 1, 250, 4, 0, 0); + + expect(calculateNadeq).toHaveBeenCalledWith(logement); + expect(calc_besoin_ecs).toHaveBeenCalledWith('ca1', 'h1a', 2.5); + expect(calc_besoin_fr).toHaveBeenCalledWith( + 'ca1', + 'h1a', + 100, + 2.5, + 250, + 'moyenne', + ['bv'], + ['ets'] + ); + // les apports internes/solaires reçoivent l'indicateur ilpa + expect(calc_ai).toHaveBeenCalledWith(4, 'ca1', 'h1a', 100, 2.5); + expect(calc_as).toHaveBeenCalledWith(4, 'ca1', 'h1a', ['bv'], ['ets']); + expect(calc_sse).toHaveBeenCalledWith('ca1', 'h1a', ['bv'], ['ets']); + }); + + test('sans climatisation : besoins de froid et apports de froid remis à zéro', () => { + const ret = calc_apport_et_besoin(logementFixture(), 'maison', [], [], 100, 1, 250, 0, 0, 0); + + expect(ret.besoin_fr).toBe(0); + expect(ret.besoin_fr_depensier).toBe(0); + expect(ret.apport_interne_fr).toBe(0); + expect(ret.apport_solaire_fr).toBe(0); + // les apports de chauffage restent inchangés + expect(ret.apport_interne_ch).toBe(10); + expect(ret.apport_solaire_ch).toBe(30); + }); +}); diff --git a/src/core/assets/domain/add-additionnal-ue-values-tables.spec.js b/src/core/assets/domain/add-additionnal-ue-values-tables.spec.js new file mode 100644 index 0000000..97de086 --- /dev/null +++ b/src/core/assets/domain/add-additionnal-ue-values-tables.spec.js @@ -0,0 +1,53 @@ +import { describe, expect, test } from 'vitest'; +import { AddAdditionnalUeValuesTables } from './add-additionnal-ue-values-tables.js'; +import { UPB_ADDITIONAL_VALUES } from '../../tv/infrastructure/assets/additional-ue-values.js'; + +/** + * `execute` n'utilise ni le fileStore ni l'appConfig : on les passe en doubles + * vides pour isoler la logique d'aplatissement des valeurs Ue additionnelles + * dans la table `ue`. + */ +describe('AddAdditionnalUeValuesTables', () => { + const buildService = () => new AddAdditionnalUeValuesTables({}, {}); + + test('ajoute toutes les valeurs additionnelles dans la table ue', () => { + const nbAttendu = UPB_ADDITIONAL_VALUES.reduce((acc, entree) => acc + entree.values.length, 0); + const tableValues = { ue: [] }; + + const resultat = buildService().execute(tableValues); + + expect(resultat).toBe(tableValues); + expect(tableValues.ue).toHaveLength(nbAttendu); + }); + + test('préserve les valeurs déjà présentes dans la table ue', () => { + const ligneExistante = { existant: true }; + const tableValues = { ue: [ligneExistante] }; + const nbAjoutees = UPB_ADDITIONAL_VALUES.reduce((acc, entree) => acc + entree.values.length, 0); + + buildService().execute(tableValues); + + expect(tableValues.ue[0]).toBe(ligneExistante); + expect(tableValues.ue).toHaveLength(nbAjoutees + 1); + }); + + test('chaque ligne ajoutée fusionne les métadonnées d’adjacence et les valeurs', () => { + const tableValues = { ue: [] }; + + buildService().execute(tableValues); + + const premiereEntree = UPB_ADDITIONAL_VALUES[0]; + const premiereValeur = premiereEntree.values[0]; + + expect(tableValues.ue[0]).toEqual({ + type_adjacence_plancher: premiereEntree.type_adjacence_plancher, + enum_type_adjacence_id: premiereEntree.enum_type_adjacence_id, + type_adjacence: premiereEntree.type_adjacence, + enum_periode_construction_id: premiereEntree.enum_periode_construction_id, + periode_construction: premiereEntree.periode_construction, + '2s_p': premiereValeur['2s_p'], + upb: premiereValeur.upb, + ue: premiereValeur.ue + }); + }); +}); diff --git a/src/core/conf/infrastructure/application.config.spec.js b/src/core/conf/infrastructure/application.config.spec.js new file mode 100644 index 0000000..bf568da --- /dev/null +++ b/src/core/conf/infrastructure/application.config.spec.js @@ -0,0 +1,35 @@ +import { beforeEach, describe, expect, test } from 'vitest'; +import { ApplicationConfig } from './application.config.js'; + +/** @type {ApplicationConfig} */ +let config; + +describe('ApplicationConfig', () => { + beforeEach(() => { + config = new ApplicationConfig(); + }); + + test('expose la version et l’URL du dépôt ADEME', () => { + expect(config.ademeRepositoryVersion).toBe('fix-pont-thermique-et-warning-audit-copro'); + expect(config.ademeRepositoryUrl).toBe('https://gitlab.com/observatoire-dpe/observatoire-dpe'); + }); + + test('construit l’URL du fichier des tables d’enums à partir de l’URL et de la version', () => { + expect(config.ademeEnumTablesFileUrl).toBe( + `${config.ademeRepositoryUrl}/-/raw/${config.ademeRepositoryVersion}/modele_donnee/enum_tables.xlsx?ref_type=tags&inline=false` + ); + }); + + test('construit l’URL du fichier des tables de valeurs', () => { + expect(config.ademeValeurTablesFileUrl).toBe( + `${config.ademeRepositoryUrl}/-/raw/${config.ademeRepositoryVersion}/modele_donnee/valeur_tables.xlsx?ref_type=tags&inline=false` + ); + }); + + test('expose les chemins des fichiers locaux de tables de valeurs', () => { + expect(config.solicitationsExtFilePath).toBe('src/tv/18.2_sollicitations_ext.ods'); + expect(config.c1FilePath).toBe('src/tv/18.5_c1.ods'); + expect(config.dpeGesLimitValuesFilePath).toBe('src/tv/dpe_ges_limit_values.ods'); + expect(config.assetsOutputFolder).toBe('src'); + }); +}); diff --git a/src/core/tv/infrastructure/assets/additional-ue-values.spec.js b/src/core/tv/infrastructure/assets/additional-ue-values.spec.js new file mode 100644 index 0000000..7567540 --- /dev/null +++ b/src/core/tv/infrastructure/assets/additional-ue-values.spec.js @@ -0,0 +1,36 @@ +import { describe, expect, test } from 'vitest'; +import { UPB_ADDITIONAL_VALUES } from './additional-ue-values.js'; + +/** + * `UPB_ADDITIONAL_VALUES` complète la table de valeurs Ue pour les + * déperditions des planchers bas. On vérifie la cohérence structurelle des + * entrées sans figer l'intégralité des valeurs numériques. + */ +describe('Valeurs Ue additionnelles (UPB_ADDITIONAL_VALUES)', () => { + test('est un tableau non vide', () => { + expect(Array.isArray(UPB_ADDITIONAL_VALUES)).toBe(true); + expect(UPB_ADDITIONAL_VALUES.length).toBeGreaterThan(0); + }); + + test('chaque entrée possède les métadonnées d’adjacence attendues', () => { + UPB_ADDITIONAL_VALUES.forEach((entree) => { + expect(entree).toHaveProperty('type_adjacence_plancher'); + expect(entree).toHaveProperty('enum_type_adjacence_id'); + expect(entree).toHaveProperty('type_adjacence'); + expect(entree).toHaveProperty('enum_periode_construction_id'); + expect(entree).toHaveProperty('periode_construction'); + expect(Array.isArray(entree.values)).toBe(true); + expect(entree.values.length).toBeGreaterThan(0); + }); + }); + + test('chaque valeur contient les clés 2s_p, upb et ue exploitables numériquement', () => { + UPB_ADDITIONAL_VALUES.forEach((entree) => { + entree.values.forEach((valeur) => { + expect(valeur).toHaveProperty('2s_p'); + expect(Number.isNaN(parseFloat(valeur.upb))).toBe(false); + expect(Number.isNaN(parseFloat(valeur.ue))).toBe(false); + }); + }); + }); +}); diff --git a/src/core/util/logger/log-service.spec.js b/src/core/util/logger/log-service.spec.js new file mode 100644 index 0000000..2f47e4f --- /dev/null +++ b/src/core/util/logger/log-service.spec.js @@ -0,0 +1,67 @@ +import { afterEach, describe, expect, test } from 'vitest'; +import { logger, setLoggerOff, setLoggerOn } from './log-service.js'; + +/** + * Sauvegarde des méthodes console d'origine : `setLoggerOff`/`setLoggerOn` + * réaffectent les globales `console.*`, on restaure après chaque test pour + * garantir l'isolation. + */ +const consoleOrigine = { + log: console.log, + warn: console.warn, + debug: console.debug, + error: console.error +}; + +describe('Service de logs (log-service)', () => { + afterEach(() => { + console.log = consoleOrigine.log; + console.warn = consoleOrigine.warn; + console.debug = consoleOrigine.debug; + console.error = consoleOrigine.error; + logger.silent = false; + }); + + test('expose un logger winston avec les méthodes de niveau usuelles', () => { + expect(logger).toBeDefined(); + expect(typeof logger.info).toBe('function'); + expect(typeof logger.warn).toBe('function'); + expect(typeof logger.error).toBe('function'); + expect(logger.level).toBe('info'); + }); + + test('setLoggerOff rend le logger silencieux et neutralise log/warn/debug', () => { + setLoggerOff(); + + expect(logger.silent).toBe(true); + expect(console.log).not.toBe(consoleOrigine.log); + expect(console.warn).not.toBe(consoleOrigine.warn); + expect(console.debug).not.toBe(consoleOrigine.debug); + // Par défaut, console.error n'est pas neutralisé + expect(console.error).toBe(consoleOrigine.error); + }); + + test('setLoggerOff(true) neutralise aussi console.error', () => { + setLoggerOff(true); + + expect(console.error).not.toBe(consoleOrigine.error); + }); + + test('setLoggerOn réactive le logger et restaure les méthodes console', () => { + setLoggerOff(true); + setLoggerOn(true); + + expect(logger.silent).toBe(false); + expect(console.log).toBe(consoleOrigine.log); + expect(console.warn).toBe(consoleOrigine.warn); + expect(console.debug).toBe(consoleOrigine.debug); + expect(console.error).toBe(consoleOrigine.error); + }); + + test('les méthodes console neutralisées sont des fonctions no-op', () => { + setLoggerOff(); + + expect(() => console.log('ignoré')).not.toThrow(); + expect(console.log('ignoré')).toBeUndefined(); + }); +}); diff --git a/src/dpe-sanitizer.service.spec.js b/src/dpe-sanitizer.service.spec.js new file mode 100644 index 0000000..08d0334 --- /dev/null +++ b/src/dpe-sanitizer.service.spec.js @@ -0,0 +1,138 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +/** + * Dépendances mockées pour isoler `DpeSanitizerService` : + * - `lodash-es` : `has`/`set` contrôlés pour observer la création des collections + * manquantes ; + * - `ObjectUtil.deepObjectTransform` : mocké pour capturer la fonction de + * transformation des valeurs et la tester unitairement, sans parcourir un vrai DPE. + */ +vi.mock('lodash-es', () => ({ + has: vi.fn(), + set: vi.fn() +})); + +vi.mock('./core/util/infrastructure/object-util.js', () => ({ + ObjectUtil: { + deepObjectTransform: vi.fn() + } +})); + +const { default: DpeSanitizerService } = await import('./dpe-sanitizer.service.js'); +const { has, set } = await import('lodash-es'); +const { ObjectUtil } = await import('./core/util/infrastructure/object-util.js'); + +/** Liste des collections dont l'absence doit être compensée par un tableau vide. */ +const COLLECTIONS = [ + 'logement.enveloppe.mur_collection.mur', + 'logement.enveloppe.plancher_bas_collection.plancher_bas', + 'logement.enveloppe.plancher_haut_collection.plancher_haut', + 'logement.ventilation_collection.ventilation', + 'logement.climatisation_collection.climatisation', + 'logement.enveloppe.baie_vitree_collection.baie_vitree', + 'logement.enveloppe.porte_collection.porte', + 'logement.enveloppe.pont_thermique_collection.pont_thermique' +]; + +let service; + +beforeEach(() => { + vi.mocked(has).mockReset(); + vi.mocked(set).mockReset(); + vi.mocked(ObjectUtil.deepObjectTransform).mockReset(); + vi.mocked(ObjectUtil.deepObjectTransform).mockImplementation((dpe) => dpe); + service = new DpeSanitizerService(); +}); + +describe('DpeSanitizerService.execute - collections manquantes', () => { + test('crée un tableau vide pour chaque collection absente', () => { + vi.mocked(has).mockReturnValue(false); + const dpe = {}; + + service.execute(dpe); + + expect(set).toHaveBeenCalledTimes(COLLECTIONS.length); + for (const path of COLLECTIONS) { + expect(set).toHaveBeenCalledWith(dpe, path, []); + } + }); + + test('ne recrée pas les collections déjà présentes', () => { + vi.mocked(has).mockReturnValue(true); + + service.execute({}); + + expect(set).not.toHaveBeenCalled(); + }); + + test('délègue la transformation profonde et retourne son résultat', () => { + vi.mocked(has).mockReturnValue(true); + const transforme = { transforme: true }; + vi.mocked(ObjectUtil.deepObjectTransform).mockReturnValue(transforme); + + const resultat = service.execute({ brut: true }); + + expect(ObjectUtil.deepObjectTransform).toHaveBeenCalledOnce(); + expect(resultat).toBe(transforme); + }); +}); + +/** + * Récupère la fonction de transformation des valeurs passée en 3e argument de + * `deepObjectTransform` après un appel à `execute`. + */ +function getValueTransform() { + vi.mocked(has).mockReturnValue(true); + service.execute({}); + return vi.mocked(ObjectUtil.deepObjectTransform).mock.calls[0][2]; +} + +describe('DpeSanitizerService - transformation des valeurs', () => { + test('encapsule dans un tableau les nœuds simples à cardinalité multiple', () => { + const valueTransform = getValueTransform(); + const generateur = { id: 1 }; + // `generateur_ecs` fait partie des nœuds à transformer en tableau + expect(valueTransform(generateur, 'generateur_ecs')).toEqual([generateur]); + }); + + test('ne ré-encapsule pas un nœud déjà sous forme de tableau', () => { + const valueTransform = getValueTransform(); + const deja = [{ id: 1 }]; + // déjà un tableau => traité comme une valeur non numérique et renvoyé tel quel + expect(valueTransform(deja, 'generateur_ecs')).toBe(deja); + }); + + test("convertit les valeurs d'énumération en chaîne de caractères", () => { + const valueTransform = getValueTransform(); + expect(valueTransform(12, 'enum_type_generateur_ch_id')).toBe('12'); + expect(valueTransform(5, 'original_enum_type_generateur_ch_id')).toBe('5'); + }); + + test('une énumération nulle donne une valeur indéfinie (optional chaining)', () => { + const valueTransform = getValueTransform(); + expect(valueTransform(null, 'enum_type_generateur_ch_id')).toBeUndefined(); + }); + + test('remplace les valeurs vides ou nulles par une chaîne vide', () => { + const valueTransform = getValueTransform(); + expect(valueTransform('', 'surface_paroi_opaque')).toBe(''); + expect(valueTransform(null, 'surface_paroi_opaque')).toBe(''); + }); + + test('conserve les tableaux vides tels quels', () => { + const valueTransform = getValueTransform(); + const vide = []; + expect(valueTransform(vide, 'mur')).toBe(vide); + }); + + test('conserve les chaînes non numériques telles quelles', () => { + const valueTransform = getValueTransform(); + expect(valueTransform('maison', 'description')).toBe('maison'); + }); + + test('convertit les chaînes numériques en nombres', () => { + const valueTransform = getValueTransform(); + expect(valueTransform('40.94', 'surface_paroi_opaque')).toBe(40.94); + expect(valueTransform('12', 'quantite')).toBe(12); + }); +}); diff --git a/src/engine.spec.js b/src/engine.spec.js new file mode 100644 index 0000000..9218405 --- /dev/null +++ b/src/engine.spec.js @@ -0,0 +1,180 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +/** + * `engine.js` est l'orchestrateur du moteur. On ne teste pas `calcul_3cl` de bout + * en bout : on isole les fonctions unitairement testables (`getVersion`, + * `get_conso_coeff_1_9_2026`, `get_classe_ges_dpe`) et on ajoute un test de garde + * léger sur `calcul_3cl` / `calcul_3cl_xml`. + * + * Toutes les dépendances importées par le module sont mockées : les calculs + * métier (déperdition, conso, ecs, ...) ne doivent pas s'exécuter, seules les + * fonctions et enums réellement sollicitées par les fonctions testées sont + * contrôlées. + */ +vi.mock('./conso.js', () => ({ + default: vi.fn(), + classe_bilan_dpe: vi.fn(), + classe_emission_ges: vi.fn(), + coef_ep: vi.fn(), + coef_ep_2_3: vi.fn() +})); + +vi.mock('./utils.js', () => ({ + add_references: vi.fn(), + bug_for_bug_compat: false, + collectionCanBeEmpty: vi.fn(), + containsAnySubstring: vi.fn(), + isEffetJoule: vi.fn(), + use_enum_as_string: false, + useEnumAsString: vi.fn(), + xmlParser: { parse: vi.fn() } +})); + +vi.mock('./enums.js', () => ({ + default: { + methode_application_dpe_log: {}, + modele_dpe: {} + } +})); + +// Dépendances de calcul mockées à vide : elles ne doivent pas s'exécuter. +vi.mock('./3_deperdition.js', () => ({ default: vi.fn() })); +vi.mock('./apport_et_besoin.js', () => ({ default: vi.fn() })); +vi.mock('./10_clim.js', () => ({ default: vi.fn() })); +vi.mock('./11_ecs.js', () => ({ default: vi.fn() })); +vi.mock('./9_besoin_ch.js', () => ({ default: vi.fn() })); +vi.mock('./9_chauffage.js', () => ({ default: vi.fn(), tauxChargeForGenerator: vi.fn() })); +vi.mock('./2021_04_13_confort_ete.js', () => ({ default: vi.fn() })); +vi.mock('./2021_04_13_qualite_isolation.js', () => ({ default: vi.fn() })); +vi.mock('./7_inertie.js', () => ({ Inertie: vi.fn() })); +vi.mock('./ficheTechnique.js', () => ({ default: vi.fn() })); +vi.mock('./16.2_production_enr.js', () => ({ ProductionENR: vi.fn() })); + +// Le service de sanitisation est instancié au chargement puis appelé : on fournit +// une instance dont `execute` renvoie l'entrée telle quelle. +const sanitizeExecute = vi.fn((dpe) => dpe); +vi.mock('./dpe-sanitizer.service.js', () => ({ + default: vi.fn(() => ({ execute: sanitizeExecute })) +})); + +const { getVersion, get_conso_coeff_1_9_2026, get_classe_ges_dpe, calcul_3cl, calcul_3cl_xml } = + await import('./engine.js'); +const { classe_bilan_dpe, classe_emission_ges } = await import('./conso.js'); +const { xmlParser } = await import('./utils.js'); +const enums = (await import('./enums.js')).default; + +beforeEach(() => { + vi.mocked(classe_bilan_dpe).mockReset(); + vi.mocked(classe_emission_ges).mockReset(); + vi.mocked(xmlParser.parse).mockReset(); + enums.methode_application_dpe_log = { 1: 'dpe maison individuelle' }; + enums.modele_dpe = { 1: 'dpe 3cl 2021 méthode logement', 99: 'modèle non supporté' }; +}); + +/** Construit un DPE minimal exploitable par les fonctions de sortie. */ +function dpeMinimal(overrides = {}) { + return { + logement: { + meteo: { enum_zone_climatique_id: '1', enum_classe_altitude_id: '1' }, + caracteristique_generale: { + enum_methode_application_dpe_log_id: '1', + surface_habitable_logement: 80, + surface_habitable_immeuble: 200 + }, + sortie: { + ep_conso: { ep_conso_5_usages: 10000, ep_conso_5_usages_m2: 125 }, + ef_conso: { conso_5_usages: 6000 }, + emission_ges: { emission_ges_5_usages_m2: 30 } + } + }, + ...overrides + }; +} + +describe('getVersion', () => { + test('retourne le jeton de version remplacé au build', () => { + // En source, le jeton n'est pas encore substitué. + expect(getVersion()).toBe('OPEN3CL_VERSION'); + }); +}); + +describe('get_classe_ges_dpe', () => { + test('délègue le classement DPE et GES aux fonctions de conso avec les bons arguments', () => { + vi.mocked(classe_bilan_dpe).mockReturnValue('D'); + vi.mocked(classe_emission_ges).mockReturnValue('C'); + + const dpe = dpeMinimal(); + const res = get_classe_ges_dpe(dpe); + + expect(res).toEqual({ dpeClass: 'D', gesClass: 'C' }); + // Sh = surface_habitable_logement pour une maison + expect(classe_bilan_dpe).toHaveBeenCalledWith(125, '1', '1', 80); + expect(classe_emission_ges).toHaveBeenCalledWith(30, '1', '1', 80); + }); + + test("utilise la surface de l'immeuble pour un modèle immeuble", () => { + enums.methode_application_dpe_log = { 1: 'dpe immeuble collectif' }; + vi.mocked(classe_bilan_dpe).mockReturnValue('E'); + vi.mocked(classe_emission_ges).mockReturnValue('E'); + + get_classe_ges_dpe(dpeMinimal()); + + // Sh = surface_habitable_immeuble + expect(classe_bilan_dpe).toHaveBeenCalledWith(125, '1', '1', 200); + }); +}); + +describe('get_conso_coeff_1_9_2026', () => { + test('recalcule la conso EP avec le coefficient 0,9 (valeur de référence)', () => { + vi.mocked(classe_bilan_dpe).mockReturnValue('C'); + + const res = get_conso_coeff_1_9_2026(dpeMinimal()); + + // Référence figée : (0.9/1.3)*(10000-6000)+6000 + expect(res.ep_conso_5_usages).toBeCloseTo(8769.23076923077, 6); + // Math.floor(8769.23.../80) + expect(res.ep_conso_5_usages_m2).toBe(109); + expect(res.classe_bilan_dpe).toBe('C'); + expect(classe_bilan_dpe).toHaveBeenCalledWith(109, '1', '1', 80); + }); + + test('la conso EP recalculée est inférieure à la conso EP initiale', () => { + vi.mocked(classe_bilan_dpe).mockReturnValue('C'); + const dpe = dpeMinimal(); + + const res = get_conso_coeff_1_9_2026(dpe); + + expect(res.ep_conso_5_usages).toBeLessThan(dpe.logement.sortie.ep_conso.ep_conso_5_usages); + }); +}); + +describe('calcul_3cl - garde sur le modèle de DPE', () => { + test('retourne null pour un modèle de DPE non supporté', () => { + const dpe = { administratif: { enum_modele_dpe_id: '99' } }; + + const res = calcul_3cl(dpe, { sanitize: false }); + + expect(res).toBeNull(); + }); + + test("sanitise l'entrée par défaut avant de vérifier le modèle", () => { + const dpe = { administratif: { enum_modele_dpe_id: '99' } }; + + const res = calcul_3cl(dpe); // pas d'options => sanitize par défaut + + expect(sanitizeExecute).toHaveBeenCalledWith(dpe); + expect(res).toBeNull(); + }); +}); + +describe('calcul_3cl_xml - garde sur le modèle de DPE', () => { + test('parse le XML puis retourne null pour un modèle non supporté', () => { + const dpe = { administratif: { enum_modele_dpe_id: '99' } }; + vi.mocked(xmlParser.parse).mockReturnValue({ dpe }); + + const res = calcul_3cl_xml('', { sanitize: false }); + + expect(xmlParser.parse).toHaveBeenCalledWith(''); + expect(res).toBeNull(); + }); +}); diff --git a/src/enums.spec.js b/src/enums.spec.js new file mode 100644 index 0000000..ce588a9 --- /dev/null +++ b/src/enums.spec.js @@ -0,0 +1,46 @@ +import { describe, expect, test } from 'vitest'; +import enums from './enums.js'; + +/** + * `enums.js` est un gros mapping figé (identifiant -> libellé) issu du modèle + * de données ADEME. On ne fige pas l'intégralité du contenu : on vérifie + * uniquement des invariants structurels et la présence de quelques enums clés. + */ +describe('Table des enums (enums.js)', () => { + test('exporte un objet non vide par défaut', () => { + expect(enums).toBeTypeOf('object'); + expect(enums).not.toBeNull(); + expect(Object.keys(enums).length).toBeGreaterThan(0); + }); + + test('quelques enums clés sont présents et non vides', () => { + ['version', 'modele_dpe', 'usage_fonctionnel_batiment', 'consentement_formulaire'].forEach( + (cle) => { + expect(enums).toHaveProperty(cle); + expect(Object.keys(enums[cle]).length).toBeGreaterThan(0); + } + ); + }); + + test('chaque enum est un objet dont les libellés sont des chaînes non vides', () => { + Object.entries(enums).forEach(([nomEnum, valeurs]) => { + expect(valeurs, `enum ${nomEnum}`).toBeTypeOf('object'); + Object.values(valeurs).forEach((libelle) => { + expect(typeof libelle).toBe('string'); + expect(libelle.length).toBeGreaterThan(0); + }); + }); + }); + + test('les identifiants (clés) sont uniques au sein de chaque enum', () => { + Object.entries(enums).forEach(([nomEnum, valeurs]) => { + const cles = Object.keys(valeurs); + expect(new Set(cles).size, `enum ${nomEnum}`).toBe(cles.length); + }); + }); + + test('contenu attendu pour l’enum modele_dpe', () => { + expect(enums.modele_dpe['1']).toBe('dpe 3cl 2021 méthode logement'); + expect(enums.consentement_formulaire['0']).toBe('absence de consentement'); + }); +}); diff --git a/src/features/dpe/domain/models/type-habitation.model.spec.js b/src/features/dpe/domain/models/type-habitation.model.spec.js new file mode 100644 index 0000000..f945826 --- /dev/null +++ b/src/features/dpe/domain/models/type-habitation.model.spec.js @@ -0,0 +1,41 @@ +import { describe, expect, test } from 'vitest'; +import { TypeHabitation, TypeDpe } from './type-habitation.model.js'; + +describe('Modèle TypeHabitation', () => { + test("expose les types d'habitation attendus", () => { + expect(TypeHabitation).toEqual({ + MAISON: 'MAISON', + APPARTEMENT: 'APPARTEMENT', + IMMEUBLE: 'IMMEUBLE' + }); + }); + + test('chaque clé est auto-référencée par sa valeur', () => { + Object.entries(TypeHabitation).forEach(([cle, valeur]) => { + expect(valeur).toBe(cle); + }); + }); +}); + +describe('Modèle TypeDpe', () => { + test('expose les types de DPE attendus', () => { + expect(TypeDpe).toEqual({ + MAISON: 'MAISON', + APPARTEMENT: 'APPARTEMENT', + APPARTEMENT_A_PARTIR_IMMEUBLE: 'APPARTEMENT_A_PARTIR_IMMEUBLE', + IMMEUBLE: 'IMMEUBLE' + }); + }); + + test('les valeurs sont uniques et non vides', () => { + const valeurs = Object.values(TypeDpe); + expect(new Set(valeurs).size).toBe(valeurs.length); + valeurs.forEach((valeur) => expect(valeur.length).toBeGreaterThan(0)); + }); + + test('reprend les types communs avec TypeHabitation', () => { + expect(TypeDpe.MAISON).toBe(TypeHabitation.MAISON); + expect(TypeDpe.APPARTEMENT).toBe(TypeHabitation.APPARTEMENT); + expect(TypeDpe.IMMEUBLE).toBe(TypeHabitation.IMMEUBLE); + }); +}); diff --git a/src/features/dpe/domain/models/type-stockage.model.spec.js b/src/features/dpe/domain/models/type-stockage.model.spec.js new file mode 100644 index 0000000..1cf907c --- /dev/null +++ b/src/features/dpe/domain/models/type-stockage.model.spec.js @@ -0,0 +1,18 @@ +import { describe, expect, test } from 'vitest'; +import { TypeStockage } from './type-stockage.model.js'; + +describe('Modèle TypeStockage', () => { + test('expose les types de stockage attendus', () => { + expect(TypeStockage).toEqual({ + INSTANTANE: 'INSTANTANE', + INTEGRE: 'INTEGRE', + INDEPENDANT: 'INDEPENDANT' + }); + }); + + test('chaque clé est auto-référencée et les valeurs sont uniques', () => { + const valeurs = Object.values(TypeStockage); + Object.entries(TypeStockage).forEach(([cle, valeur]) => expect(valeur).toBe(cle)); + expect(new Set(valeurs).size).toBe(valeurs.length); + }); +}); diff --git a/src/features/dpe/domain/models/type-ventilation.model.spec.js b/src/features/dpe/domain/models/type-ventilation.model.spec.js new file mode 100644 index 0000000..d2b1594 --- /dev/null +++ b/src/features/dpe/domain/models/type-ventilation.model.spec.js @@ -0,0 +1,19 @@ +import { describe, expect, test } from 'vitest'; +import { TypeVentilation } from './type-ventilation.model.js'; + +describe('Modèle TypeVentilation', () => { + test('expose les types de ventilation attendus', () => { + expect(TypeVentilation).toEqual({ + SIMPLE_FLUX_AUTO: 'SIMPLE_FLUX_AUTO', + SIMPLE_FLUX_HYGRO: 'SIMPLE_FLUX_HYGRO', + DOUBLE_FLUX: 'DOUBLE_FLUX' + }); + }); + + test('chaque clé est auto-référencée et les valeurs sont uniques', () => { + const valeurs = Object.values(TypeVentilation); + Object.entries(TypeVentilation).forEach(([cle, valeur]) => expect(valeur).toBe(cle)); + expect(new Set(valeurs).size).toBe(valeurs.length); + valeurs.forEach((valeur) => expect(valeur.length).toBeGreaterThan(0)); + }); +}); diff --git a/src/features/engine/domain/constants.spec.js b/src/features/engine/domain/constants.spec.js new file mode 100644 index 0000000..adc68c9 --- /dev/null +++ b/src/features/engine/domain/constants.spec.js @@ -0,0 +1,14 @@ +import { describe, expect, test } from 'vitest'; +import { PRECISION } from './constants.js'; + +describe('Constantes du moteur', () => { + test('PRECISION vaut 100000', () => { + expect(PRECISION).toBe(100000); + }); + + test('PRECISION est un entier positif utilisable comme facteur d’arrondi', () => { + expect(typeof PRECISION).toBe('number'); + expect(Number.isInteger(PRECISION)).toBe(true); + expect(PRECISION).toBeGreaterThan(0); + }); +}); diff --git a/src/features/engine/domain/enveloppe/deperdition.service.spec.js b/src/features/engine/domain/enveloppe/deperdition.service.spec.js new file mode 100644 index 0000000..62b2320 --- /dev/null +++ b/src/features/engine/domain/enveloppe/deperdition.service.spec.js @@ -0,0 +1,141 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { DeperditionService } from './deperdition.service.js'; +import { logger } from '../../../../core/util/logger/log-service.js'; + +/** + * Tests unitaires isolés de la classe de base `DeperditionService`. + * + * Le `TvStore` est remplacé par un double de test (spies `vi.fn`) afin de + * n'exercer que la logique de la classe (méthodes `b` et + * `getEnumPeriodeIsolationId`), sans dépendre des tables de valeurs réelles. + */ + +/** @type {{ getUVue: import('vitest').Mock, getB: import('vitest').Mock }} */ +let tvStore; + +/** @type {DeperditionService} */ +let service; + +describe('DeperditionService (classe de base des déperditions)', () => { + beforeEach(() => { + tvStore = { + getUVue: vi.fn(), + getB: vi.fn() + }; + service = new DeperditionService(tvStore); + vi.restoreAllMocks(); + }); + + test('conserve la référence au tvStore injecté', () => { + expect(service.tvStore).toBe(tvStore); + }); + + test('execute() lève une erreur (opération non supportée sur la classe de base)', () => { + expect(() => service.execute({}, {})).toThrow('Unsupported operation'); + }); + + describe('b() - coefficient de réduction des déperditions', () => { + test('retourne 0 pour une adjacence sur AUE sans surface AUE renseignée', () => { + const resultat = service.b({ enumTypeAdjacenceId: '8', surfaceAue: 0 }); + expect(resultat).toBe(0); + expect(tvStore.getB).not.toHaveBeenCalled(); + }); + + test('retourne 0 pour une adjacence sur AUE avec surface AUE indéfinie', () => { + const resultat = service.b({ enumTypeAdjacenceId: '15' }); + expect(resultat).toBe(0); + }); + + test('local non chauffé non accessible (cfg isolation 1) : appel getB sans adjacence ni uVue', () => { + tvStore.getB.mockReturnValue(0.95); + + const resultat = service.b({ + enumTypeAdjacenceId: '9', + surfaceAue: 20, + enumCfgIsolationLncId: '1' + }); + + expect(resultat).toBe(0.95); + expect(tvStore.getUVue).not.toHaveBeenCalled(); + // enumTypeAdjacenceId remis à undefined, uVue et rAiuAue non calculés + expect(tvStore.getB).toHaveBeenCalledWith(undefined, undefined, '1', undefined, undefined); + }); + + test('adjacence sur AUE avec surfaces : calcule uVue et le ratio rAiuAue', () => { + tvStore.getUVue.mockReturnValue(2.5); + tvStore.getB.mockReturnValue(0.8); + + const resultat = service.b({ + enumTypeAdjacenceId: '8', + surfaceAue: 40, + surfaceAiu: 20, + enumCfgIsolationLncId: '2' + }); + + expect(resultat).toBe(0.8); + expect(tvStore.getUVue).toHaveBeenCalledWith('8'); + expect(tvStore.getB).toHaveBeenCalledWith('8', 2.5, '2', 0.5, undefined); + }); + + test('adjacence sur AUE : uVue vaut 0 si le tvStore ne retourne pas de valeur', () => { + tvStore.getUVue.mockReturnValue(undefined); + tvStore.getB.mockReturnValue(0.9); + + service.b({ + enumTypeAdjacenceId: '12', + surfaceAue: 10, + surfaceAiu: 5, + enumCfgIsolationLncId: '2' + }); + + expect(tvStore.getB).toHaveBeenCalledWith('12', 0, '2', 0.5, undefined); + }); + + test('espace tampon solarisé (adjacence 10) sans zone climatique : avertit et retourne undefined', () => { + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {}); + + const resultat = service.b({ enumTypeAdjacenceId: '10' }); + + expect(resultat).toBeUndefined(); + expect(warnSpy).toHaveBeenCalledOnce(); + expect(tvStore.getB).not.toHaveBeenCalled(); + }); + + test('espace tampon solarisé (adjacence 10) avec zone climatique : transmet la zone à getB', () => { + tvStore.getB.mockReturnValue(0.7); + + const resultat = service.b({ enumTypeAdjacenceId: '10', zoneClimatique: 'h1a' }); + + expect(resultat).toBe(0.7); + expect(tvStore.getB).toHaveBeenCalledWith('10', undefined, undefined, undefined, 'h1a'); + }); + + test('adjacence classique (hors listes) : délègue directement à getB', () => { + tvStore.getB.mockReturnValue(1); + + const resultat = service.b({ enumTypeAdjacenceId: '1' }); + + expect(resultat).toBe(1); + expect(tvStore.getUVue).not.toHaveBeenCalled(); + expect(tvStore.getB).toHaveBeenCalledWith('1', undefined, undefined, undefined, undefined); + }); + }); + + describe('getEnumPeriodeIsolationId()', () => { + test("retourne la période d'isolation si elle est fournie", () => { + expect(service.getEnumPeriodeIsolationId('7', { enumPeriodeConstructionId: '1' })).toBe('7'); + }); + + test("période d'isolation inconnue et construction ≤ 74 : retourne '3' (75-77)", () => { + expect(service.getEnumPeriodeIsolationId(undefined, { enumPeriodeConstructionId: '2' })).toBe( + '3' + ); + }); + + test("période d'isolation inconnue et construction récente : reprend la période de construction", () => { + expect(service.getEnumPeriodeIsolationId(undefined, { enumPeriodeConstructionId: '8' })).toBe( + '8' + ); + }); + }); +}); diff --git a/src/index.spec.js b/src/index.spec.js new file mode 100644 index 0000000..21e8148 --- /dev/null +++ b/src/index.spec.js @@ -0,0 +1,57 @@ +import { describe, expect, test, vi } from 'vitest'; + +/** + * `index.js` est un simple point d'entrée qui ré-exporte des fonctions/valeurs + * provenant de `engine.js` et `3_deperdition.js`. On mocke ces deux modules avec + * des identités reconnaissables afin de vérifier que chaque symbole est bien + * ré-exporté tel quel (même référence). + */ +vi.mock('./engine.js', () => ({ + calcul_3cl: vi.fn(), + calcul_3cl_xml: vi.fn(), + get_classe_ges_dpe: vi.fn(), + get_conso_coeff_1_9_2026: vi.fn(), + getVersion: vi.fn() +})); + +vi.mock('./3_deperdition.js', () => ({ + default: vi.fn(), + Umur: vi.fn(), + Uph: vi.fn(), + Upb: vi.fn(), + Uporte: vi.fn(), + Ubv: vi.fn(), + Upt: vi.fn() +})); + +const index = await import('./index.js'); +const engine = await import('./engine.js'); +const deperdition = await import('./3_deperdition.js'); + +describe('index - ré-exports depuis engine.js', () => { + test.each([ + 'calcul_3cl', + 'calcul_3cl_xml', + 'get_classe_ges_dpe', + 'get_conso_coeff_1_9_2026', + 'getVersion' + ])("ré-exporte %s à l'identique depuis engine.js", (nom) => { + expect(index[nom]).toBe(engine[nom]); + expect(typeof index[nom]).toBe('function'); + }); +}); + +describe('index - ré-exports depuis 3_deperdition.js', () => { + test.each(['Umur', 'Uph', 'Upb', 'Uporte', 'Ubv', 'Upt'])( + "ré-exporte %s à l'identique depuis 3_deperdition.js", + (nom) => { + expect(index[nom]).toBe(deperdition[nom]); + expect(typeof index[nom]).toBe('function'); + } + ); + + test("ne ré-exporte pas l'export par défaut de 3_deperdition.js", () => { + // index.js n'expose que les exports nommés Umur, Uph, ... + expect(index.default).toBeUndefined(); + }); +}); diff --git a/src/output.spec.js b/src/output.spec.js new file mode 100644 index 0000000..b7cd8f6 --- /dev/null +++ b/src/output.spec.js @@ -0,0 +1,17 @@ +import { describe, expect, test } from 'vitest'; + +/** + * `output.js` n'est pas un module applicatif : il est exclu du lint (`.eslintignore`) + * et ne contient qu'un identifiant orphelin `filecontent` (placeholder). + * Son évaluation en tant que module ES lève donc une `ReferenceError`. + * On se contente de figer ce comportement pour documenter l'état du fichier. + */ +describe('output - module placeholder non exécutable', () => { + test("l'import du module lève une ReferenceError (identifiant non défini)", async () => { + await expect(import('./output.js')).rejects.toThrowError(ReferenceError); + }); + + test("le message d'erreur mentionne l'identifiant `filecontent`", async () => { + await expect(import('./output.js')).rejects.toThrowError(/filecontent/); + }); +}); diff --git a/src/tv.spec.js b/src/tv.spec.js new file mode 100644 index 0000000..cc694f6 --- /dev/null +++ b/src/tv.spec.js @@ -0,0 +1,38 @@ +import { describe, expect, test } from 'vitest'; + +/** + * `tv.js` ne contient que des données : le catalogue des tables de valeurs + * du DPE 3CL. On vérifie ici uniquement la structure des exports (export nommé + * `tvs` + export par défaut identique) et quelques invariants de forme, sans + * dépendre de la valeur métier des tables. + */ +const { tvs, default: tvsDefault } = await import('./tv.js'); + +describe('tv - structure des exports', () => { + test('exporte un objet non vide `tvs`', () => { + expect(tvs).toBeTypeOf('object'); + expect(tvs).not.toBeNull(); + expect(Object.keys(tvs).length).toBeGreaterThan(0); + }); + + test("l'export par défaut est la même référence que l'export nommé", () => { + expect(tvsDefault).toBe(tvs); + }); +}); + +describe('tv - forme des tables', () => { + test('la table coef_reduction_deperdition est un tableau de lignes indexées', () => { + const table = tvs.coef_reduction_deperdition; + expect(Array.isArray(table)).toBe(true); + expect(table.length).toBeGreaterThan(0); + // chaque ligne possède son identifiant de table de valeur + expect(table[0]).toHaveProperty('tv_coef_reduction_deperdition_id'); + }); + + test('toutes les valeurs de premier niveau sont des objets (tableaux ou dictionnaires)', () => { + for (const [nom, table] of Object.entries(tvs)) { + expect(table, `table ${nom}`).toBeTypeOf('object'); + expect(table, `table ${nom}`).not.toBeNull(); + } + }); +}); From 1e007b27e75e2dfb953cb8b6f0a65f78659a619f Mon Sep 17 00:00:00 2001 From: Florent Perinel Date: Mon, 13 Jul 2026 17:07:18 +0200 Subject: [PATCH 19/20] test: add claude rules file --- CLAUDE.md | 99 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..3d9c788 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,99 @@ +# CLAUDE.md — Open3CL engine + +Guide pour toute contribution (humaine ou assistée) au moteur de calcul DPE-3CL. +Lu à chaque séance : à respecter et à tenir à jour. + +## Principes fondamentaux + +1. **Tests unitaires isolés avec des mocks.** Chaque test unitaire doit être autonome : les + dépendances (tables de valeurs, enums, utilitaires, services) sont **mockées**. Un test ne doit + pas dépendre du contenu réel des tables/enums et doit rester vert si ces données évoluent. +2. **Couverture des tests à 100 %.** Chaque fichier de `src/` possède un fichier de test + `.spec.js` placé **à côté** de sa source. Tout nouveau fichier source est accompagné de son + spec dans le même commit. + +## Outillage + +- **Runner : [vitest](https://vitest.dev/).** `npm test` = `vitest run`. `npm run test:ci` pour la + couverture. +- **Lint : ESLint** (`npm run qa:lint`, `npm run qa:lint:fix`). +- **Format : Prettier.** Toujours lancer `npx prettier --write ` avant de committer. +- Avant chaque commit d'un spec : `npx vitest run ` vert, `npx eslint ` sans + erreur, `npx prettier --check` conforme. + +## Pattern de test à respecter + +### Structure + +- Import : `import { beforeEach, describe, expect, test, vi } from 'vitest';` +- Libellés de `describe`/`test` et commentaires **en français** (accents corrects). +- Nommage : `.spec.js` à côté de la source. + +### Isolation par mocks + +- Mocker les dépendances importées par le module testé : + `vi.mock('./x.js', () => ({ ... }))`. +- **Attention au type d'import** : respecter default vs nommé. + - `import tvs from './tv.js'` (default) → mock `{ default: { ... } }`. + - `import { tvs } from './tv.js'` (nommé) → mock `{ tvs: { ... } }`. +- `vi.mock` étant **hoisté**, importer le module testé **après** les mocks via top-level await : + ```js + vi.mock('./utils.js', () => ({ tv: vi.fn(), requestInput: (de, du, f) => de[f] })); + const { default: maFonction, autreExport } = await import('./mon-module.js'); + const { tv } = await import('./utils.js'); // récupère le mock + ``` +- Réinitialiser les mocks dans `beforeEach` (`vi.mocked(fn).mockReset()` / `mock.mockReset()`). +- **Ne pas tester le comportement des utilitaires mockés** dans le spec d'un module consommateur : + cela relève du spec de l'utilitaire lui-même (ex. `utils.spec.js`). + +### Qualité des assertions (éviter les tests tautologiques) + +Un test ne doit pas se contenter de figer la sortie actuelle du code (« le code fait ce qu'il +fait »). Combiner : + +- **Tests de règles déterministes** : seuils, branches conditionnelles, cas nuls/limites, + invariants documentés par la méthode 3CL. C'est le cœur de la valeur du test. +- **Valeurs de référence de régression** pour les formules numériques : calculer la valeur en + exécutant le vrai module via + `node --input-type=module -e "import fn from './src/....js'; ...; console.log(...)"`, puis figer + avec `toBeCloseTo(valeur, 9)` en la **commentant** comme référence de régression. +- Vérifier aussi la **logique métier propre au fichier** (ex. le _matcher_ construit et passé à + `tv()`), pas seulement le résultat final. +- Ajouter un commentaire `@see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §X` quand pertinent. + +### Testabilité du code source + +- Il est acceptable d'ajouter un `export` sur une **fonction pure interne** pour la tester + unitairement (changement minime, sans impact comportemental). Exemples déjà en place : + `findRanges` (`3.1_b.js`), `calc_hperm` (`4_ventilation.js`), `calc_Fj`/`calc_bvj` + (`9_besoin_ch.js`), `qualite_isol` (`2021_04_13_qualite_isolation.js`). + +## Architecture + +Deux architectures cohabitent (migration progressive) : + +- **Legacy** : fichiers numérotés à la racine de `src/` (ex. `3_deperdition.js`, `9_chauffage.js`), + encore actifs via `src/engine.js`. Dépendances via imports directs (`tv.js`, `enums.js`, + `utils.js`). +- **Nouvelle archi `features/`** : services injectés via `dioma`. Pour ces tests, imiter les specs + voisins (`*.service.spec.js`) — souvent `vi.spyOn` sur des services injectés / doubles de test. + +## Fichiers de test de référence + +À consulter comme modèles avant d'écrire un nouveau spec : + +- `src/3.1_b.spec.js` — mock de `utils`/`enums`, vérification du matcher construit. +- `src/15_conso_aux.spec.js` — fonction autonome + fonction dépendante mockée. +- `src/10_besoin_fr.spec.js` — règles déterministes (seuils) + valeurs de régression + délégation. +- `src/6.2_surface_sud_equivalente.spec.js` — mocks de tables et calcul multi-branches. +- `src/features/engine/domain/enveloppe/deperdition.service.spec.js` — nouvelle archi, double de + test injecté. + +## Points d'attention connus + +- `src/output.js` est un fichier **mort/cassé** (identifiant orphelin `filecontent`, lève une + `ReferenceError` à l'import ; présent dans `.eslintignore`). À nettoyer. +- La suite globale `vitest run src/` comporte des specs **préexistants** en échec, liés à des + données/assets manquants dans l'environnement (ex. `test/corpus/corpus_dpe.csv` pour + `csv-parser.store.spec.js`, plusieurs `features/**/*.service.spec.js`). Ne pas les confondre avec + les régressions introduites par une contribution : vérifier l'état avant/après. From 55881b767a2956389241b90cc6e9dea28899f230 Mon Sep 17 00:00:00 2001 From: Florent Perinel Date: Mon, 13 Jul 2026 18:22:30 +0200 Subject: [PATCH 20/20] ci: add continuous tests --- .github/workflows/build.yml | 49 ++- package-lock.json | 331 ++++++++++++------ .../infrastructure/adapter/file.store.spec.js | 10 +- .../domain/models/type-generateur.model.js | 3 +- .../models/type-generateur.model.spec.js | 3 +- .../dpe/infrastructure/ch/chTv.store.spec.js | 8 +- src/features/dpe/infrastructure/tv.store.js | 21 +- .../apport-et-besoin.service.spec.js | 3 +- .../ch/perte-ch-recup.service.spec.js | 3 +- .../ecs/besoin-ecs.service.spec.js | 3 +- .../ecs/perte-ecs-recup.service.spec.js | 3 +- .../froid/besoin-froid.service.spec.js | 3 +- .../surface-sud-equivalente.service.spec.js | 3 +- .../domain/ch/generateur-ch.service.spec.js | 5 +- .../conso/froid/conso-froid.service.spec.js | 3 +- .../ecs/installation-ecs.service.spec.js | 3 +- .../deperdition-baie-vitree.service.spec.js | 3 +- .../deperdition-enveloppe.service.spec.js | 3 +- .../espace-tampon.service.spec.js | 3 +- .../mur/deperdition-mur.service.spec.js | 3 +- .../deperdition-plancher-bas.service.spec.js | 3 +- .../deperdition-plancher-haut.service.spec.js | 3 +- ...deperdition-pont-thermique.service.spec.js | 3 +- .../porte/deperdition-porte.service.spec.js | 3 +- .../deperdition-ventilation.service.spec.js | 3 +- .../domain/dpe-normalizer.service.js | 2 +- src/preprocessor/index.js | 20 ++ src/utils.js | 41 ++- src/utils.spec.js | 42 ++- test/helpers/integration-test.js | 19 + 30 files changed, 463 insertions(+), 142 deletions(-) create mode 100644 src/preprocessor/index.js create mode 100644 test/helpers/integration-test.js diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index cbbf3eb..a65d205 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -12,14 +12,15 @@ permissions: pages: write issues: write id-token: write + checks: write concurrency: group: 'pages' cancel-in-progress: false jobs: - build: - name: Build + test: + name: Unit tests runs-on: ubuntu-24.04 env: NODE_OPTIONS: '--max-old-space-size=4096' @@ -44,8 +45,47 @@ jobs: run: npm run assets:sync - name: Run unit tests with coverage - run: npm run test:ci - continue-on-error: true + run: npm run test:ci -- --reporter=default --reporter=junit --outputFile.junit=./test-results/junit.xml + + - name: Publish test results + uses: actions/upload-artifact@v4 + if: always() + with: + name: test-results + path: ./test-results/junit.xml + + - name: Publish coverage report + uses: actions/upload-artifact@v4 + if: always() + with: + name: coverage + path: ./coverage + + build: + name: Build + needs: test + runs-on: ubuntu-24.04 + env: + NODE_OPTIONS: '--max-old-space-size=4096' + steps: + - uses: actions/setup-node@v4 + with: + node-version: 22.17.0 + + - name: Git checkout for source code analysis + uses: actions/checkout@v4 + + - name: Install Dependencies + run: npm ci + + - name: Cache Node.js modules + uses: actions/cache@v4 + with: + path: ~/.npm + key: ${{ runner.OS }}-node-${{ hashFiles('**/package-lock.json') }} + + - name: Generate assets + run: npm run assets:sync - name: Run qa run: | @@ -62,6 +102,7 @@ jobs: cp README.md src/ npm run release -- --r git@github.com:Open3CL/engine.git deploy: + needs: build environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} diff --git a/package-lock.json b/package-lock.json index ea18f3d..42f3df1 100755 --- a/package-lock.json +++ b/package-lock.json @@ -2067,7 +2067,6 @@ "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.7.tgz", "integrity": "sha512-m+Trk77mp54Zma6xLkLuY+mvanPxlE4A7yNKs2HBiyZ4UkVs28Mv5c/pgWrHeInx+USHeX/WEPzjrWrcJiQgjw==", "dev": true, - "peer": true, "engines": { "node": ">=18" } @@ -2708,6 +2707,7 @@ "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", @@ -4762,6 +4762,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.11.tgz", "integrity": "sha512-y+cTCACu92FyA5fgQSAI8A1H429g7aSK2HsO7K4XYUWc4dY5IUz55JSDIYT6/VsOLfGy8vmvQYC2hfb0iF16Uw==", "dev": true, + "peer": true, "dependencies": { "undici-types": "~6.19.2" } @@ -5009,6 +5010,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", "dev": true, + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5102,7 +5104,6 @@ "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", "dev": true, - "peer": true, "dependencies": { "type-fest": "^0.21.3" }, @@ -5118,7 +5119,6 @@ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", "dev": true, - "peer": true, "engines": { "node": ">=10" }, @@ -5415,8 +5415,7 @@ "type": "consulting", "url": "https://feross.org/support" } - ], - "peer": true + ] }, "node_modules/basic-auth": { "version": "2.0.1", @@ -5448,7 +5447,6 @@ "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", "dev": true, - "peer": true, "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", @@ -5570,7 +5568,6 @@ "url": "https://feross.org/support" } ], - "peer": true, "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" @@ -5751,7 +5748,6 @@ "resolved": "https://registry.npmjs.org/cachedir/-/cachedir-2.3.0.tgz", "integrity": "sha512-A+Fezp4zxnit6FanDmv9EqXNAi3vt9DWp51/71UEhXukb7QUuvtv9344h91dyAxuTLoSYJFU299qzR3tzwPAhw==", "dev": true, - "peer": true, "engines": { "node": ">=6" } @@ -5878,8 +5874,7 @@ "version": "0.7.0", "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", - "dev": true, - "peer": true + "dev": true }, "node_modules/check-error": { "version": "2.1.1", @@ -5914,7 +5909,6 @@ "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", "dev": true, - "peer": true, "dependencies": { "restore-cursor": "^3.1.0" }, @@ -6037,7 +6031,6 @@ "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", "dev": true, - "peer": true, "engines": { "node": ">=6" }, @@ -6065,7 +6058,6 @@ "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", "dev": true, - "peer": true, "engines": { "node": ">= 12" } @@ -6106,7 +6098,6 @@ "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", "dev": true, - "peer": true, "engines": { "node": ">=0.8" } @@ -6184,7 +6175,6 @@ "resolved": "https://registry.npmjs.org/commitizen/-/commitizen-4.3.1.tgz", "integrity": "sha512-gwAPAVTy/j5YcOOebcCRIijn+mSjWJC+IYKivTu6aG8Ei/scoXgfsMRnuAk6b0GRste2J4NGxVdMN3ZpfNaVaw==", "dev": true, - "peer": true, "dependencies": { "cachedir": "2.3.0", "cz-conventional-changelog": "3.3.0", @@ -6215,7 +6205,6 @@ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, - "peer": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -6232,7 +6221,6 @@ "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-3.0.0.tgz", "integrity": "sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==", "dev": true, - "peer": true, "engines": { "node": ">= 10" } @@ -6242,7 +6230,6 @@ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", "dev": true, - "peer": true, "engines": { "node": ">=0.8.0" } @@ -6252,7 +6239,6 @@ "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", "dev": true, - "peer": true, "dependencies": { "escape-string-regexp": "^1.0.5" }, @@ -6268,7 +6254,6 @@ "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", "dev": true, - "peer": true, "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", @@ -6284,7 +6269,6 @@ "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-8.2.5.tgz", "integrity": "sha512-QAgPDQMEgrDssk1XiwwHoOGYF9BAbUcc1+j+FhEvaOt8/cKRqyLn0U5qA6F74fGhTMGxf92pOvPBeh29jQJDTQ==", "dev": true, - "peer": true, "dependencies": { "ansi-escapes": "^4.2.1", "chalk": "^4.1.1", @@ -6311,7 +6295,6 @@ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.7.tgz", "integrity": "sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g==", "dev": true, - "peer": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -6320,15 +6303,13 @@ "version": "0.0.8", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", - "dev": true, - "peer": true + "dev": true }, "node_modules/commitizen/node_modules/run-async": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", "integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==", "dev": true, - "peer": true, "engines": { "node": ">=0.12.0" } @@ -6338,7 +6319,6 @@ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "dev": true, - "peer": true, "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", @@ -6485,8 +6465,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/conventional-commit-types/-/conventional-commit-types-3.0.0.tgz", "integrity": "sha512-SmmCYnOniSsAa9GqWOeLqc179lfr5TRu5b4QFDkbsrJ5TZjPJx85wtOr3zn+1dbeNiXDKGPbZ72IKbPhLXh/Lg==", - "dev": true, - "peer": true + "dev": true }, "node_modules/conventional-commits-filter": { "version": "5.0.0", @@ -6548,6 +6527,7 @@ "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", "dev": true, + "peer": true, "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", @@ -6647,7 +6627,6 @@ "resolved": "https://registry.npmjs.org/cz-conventional-changelog/-/cz-conventional-changelog-3.3.0.tgz", "integrity": "sha512-U466fIzU5U22eES5lTNiNbZ+d8dfcHcssH4o7QsdWaCcRs/feIPCxKYSWkYBNs5mny7MvEfwpTLWjvbm94hecw==", "dev": true, - "peer": true, "dependencies": { "chalk": "^2.4.1", "commitizen": "^4.0.3", @@ -6668,7 +6647,6 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", "dev": true, - "peer": true, "dependencies": { "color-convert": "^1.9.0" }, @@ -6681,7 +6659,6 @@ "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", "dev": true, - "peer": true, "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", @@ -6696,7 +6673,6 @@ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", "dev": true, - "peer": true, "engines": { "node": ">=0.8.0" } @@ -6706,7 +6682,6 @@ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", "dev": true, - "peer": true, "engines": { "node": ">=4" } @@ -6716,7 +6691,6 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", "dev": true, - "peer": true, "dependencies": { "has-flag": "^3.0.0" }, @@ -6813,8 +6787,7 @@ "version": "0.7.0", "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz", "integrity": "sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==", - "dev": true, - "peer": true + "dev": true }, "node_modules/deep-eql": { "version": "5.0.2", @@ -6852,7 +6825,6 @@ "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", "dev": true, - "peer": true, "dependencies": { "clone": "^1.0.2" }, @@ -6906,7 +6878,6 @@ "resolved": "https://registry.npmjs.org/detect-file/-/detect-file-1.0.0.tgz", "integrity": "sha512-DtCOLG98P007x7wiiOmfI0fi3eIKyWiLTGJ2MDnVi/E04lWGbf+JzrRHMm0rgIIZJGtHpKpbVgLWHrv8xXpc3Q==", "dev": true, - "peer": true, "engines": { "node": ">=0.10.0" } @@ -6916,7 +6887,6 @@ "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-6.1.0.tgz", "integrity": "sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==", "dev": true, - "peer": true, "engines": { "node": ">=8" } @@ -7074,31 +7044,6 @@ "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==" }, - "node_modules/encoding": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", - "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "iconv-lite": "^0.6.2" - } - }, - "node_modules/encoding/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/end-of-stream": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", @@ -7502,6 +7447,7 @@ "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -7700,6 +7646,7 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.31.0.tgz", "integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==", "dev": true, + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.8", @@ -7763,6 +7710,7 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-n/-/eslint-plugin-n-16.6.2.tgz", "integrity": "sha512-6TyDmZ1HXoFQXnhCTUjVFULReoBPOAjpuiKELMkeP40yffI/1ZRO+d9ug/VC6fqISo2WkuIBk3cvuRPALaWlOQ==", "dev": true, + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "builtins": "^5.0.1", @@ -7791,6 +7739,7 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-promise/-/eslint-plugin-promise-6.6.0.tgz", "integrity": "sha512-57Zzfw8G6+Gq7axm2Pdo3gW/Rx3h9Yywgn61uE/3elTCOePEHVrn2i5CdfBwA1BLK0Q0WqctICIUSqXZW/VprQ==", "dev": true, + "peer": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, @@ -8024,7 +7973,6 @@ "resolved": "https://registry.npmjs.org/expand-tilde/-/expand-tilde-2.0.2.tgz", "integrity": "sha512-A5EmesHW6rfnZ9ysHQjPdJRni0SRar0tjtG5MNtm9n5TUvsYU8oozprtRD4AqHxcZWWlVuAmQo2nWKfN9oyjTw==", "dev": true, - "peer": true, "dependencies": { "homedir-polyfill": "^1.0.1" }, @@ -8053,7 +8001,6 @@ "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", "integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==", "dev": true, - "peer": true, "dependencies": { "chardet": "^0.7.0", "iconv-lite": "^0.4.24", @@ -8252,7 +8199,6 @@ "resolved": "https://registry.npmjs.org/find-node-modules/-/find-node-modules-2.1.3.tgz", "integrity": "sha512-UC2I2+nx1ZuOBclWVNdcnbDR5dlrOdVb7xNjmT/lHE+LsgztWks3dG7boJ37yTS/venXw84B/mAW9uHVoC5QRg==", "dev": true, - "peer": true, "dependencies": { "findup-sync": "^4.0.0", "merge": "^2.1.1" @@ -8262,8 +8208,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", - "dev": true, - "peer": true + "dev": true }, "node_modules/find-up": { "version": "7.0.0", @@ -8316,7 +8261,6 @@ "resolved": "https://registry.npmjs.org/findup-sync/-/findup-sync-4.0.0.tgz", "integrity": "sha512-6jvvn/12IC4quLBL1KNokxC7wWTvYncaVUYSoxWw7YykPLuRrnv4qdHcSOywOI5RpkOVGeQRtWM8/q+G6W6qfQ==", "dev": true, - "peer": true, "dependencies": { "detect-file": "^1.0.0", "is-glob": "^4.0.0", @@ -8812,7 +8756,6 @@ "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-1.0.0.tgz", "integrity": "sha512-sKzpEkf11GpOFuw0Zzjzmt4B4UZwjOcG757PPvrfhxcLFbq0wpsgpOqxpxtxFiCG4DtG93M6XRVbF2oGdev7bg==", "dev": true, - "peer": true, "dependencies": { "global-prefix": "^1.0.1", "is-windows": "^1.0.1", @@ -8827,7 +8770,6 @@ "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-1.0.2.tgz", "integrity": "sha512-5lsx1NUDHtSjfg0eHlmYvZKv8/nVqX4ckFbM+FrGcQ+04KWcWFo9P5MxPZYSzUvyzmdTbI7Eix8Q4IbELDqzKg==", "dev": true, - "peer": true, "dependencies": { "expand-tilde": "^2.0.2", "homedir-polyfill": "^1.0.1", @@ -8843,15 +8785,13 @@ "version": "1.3.8", "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "dev": true, - "peer": true + "dev": true }, "node_modules/global-prefix/node_modules/which": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", "dev": true, - "peer": true, "dependencies": { "isexe": "^2.0.0" }, @@ -9046,7 +8986,6 @@ "resolved": "https://registry.npmjs.org/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz", "integrity": "sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==", "dev": true, - "peer": true, "dependencies": { "parse-passwd": "^1.0.0" }, @@ -9261,7 +9200,6 @@ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", "dev": true, - "peer": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3" }, @@ -9287,8 +9225,7 @@ "type": "consulting", "url": "https://feross.org/support" } - ], - "peer": true + ] }, "node_modules/ignore": { "version": "5.3.2", @@ -9659,7 +9596,6 @@ "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", "dev": true, - "peer": true, "engines": { "node": ">=8" } @@ -9847,7 +9783,6 @@ "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", "dev": true, - "peer": true, "engines": { "node": ">=10" }, @@ -9859,8 +9794,7 @@ "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-utf8/-/is-utf8-0.2.1.tgz", "integrity": "sha512-rMYPYvCzsXywIsldgLaSoPlw5PfoB/ssr7hY4pLfcodrA5M/eArza1a9VmTiNIBNMjOGr1Ow9mTyU2o69U6U9Q==", - "dev": true, - "peer": true + "dev": true }, "node_modules/is-weakref": { "version": "1.0.2", @@ -9879,7 +9813,6 @@ "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", "dev": true, - "peer": true, "engines": { "node": ">=0.10.0" } @@ -10312,8 +10245,7 @@ "version": "4.6.0", "resolved": "https://registry.npmjs.org/lodash.map/-/lodash.map-4.6.0.tgz", "integrity": "sha512-worNHGKLDetmcEYDvh2stPCrrQRkP20E4l0iIS7F8EvzMqBBi7ltvFN5m1HvTf1P7Jk1txKhvFcmYsCr8O2F1Q==", - "dev": true, - "peer": true + "dev": true }, "node_modules/lodash.merge": { "version": "4.6.2", @@ -10362,7 +10294,6 @@ "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", "dev": true, - "peer": true, "dependencies": { "chalk": "^4.1.0", "is-unicode-supported": "^0.1.0" @@ -10379,7 +10310,6 @@ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, - "peer": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -10420,7 +10350,6 @@ "resolved": "https://registry.npmjs.org/longest/-/longest-2.0.1.tgz", "integrity": "sha512-Ajzxb8CM6WAnFjgiloPsI3bF+WCxcvhdIG3KNA2KN962+tdBsHcuQ4k4qX/EcS/2CRkcc0iAkR956Nib6aXU/Q==", "dev": true, - "peer": true, "engines": { "node": ">=0.10.0" } @@ -10578,6 +10507,7 @@ "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", "dev": true, "license": "MIT", + "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -10684,8 +10614,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/merge/-/merge-2.1.1.tgz", "integrity": "sha512-jz+Cfrg9GWOZbQAnDQ4hlVnQky+341Yk5ru8bZSe6sIDTCIg8n9i/u7hSQGSVOF3C7lH6mGtqjkiT9G4wFLL0w==", - "dev": true, - "peer": true + "dev": true }, "node_modules/merge-stream": { "version": "2.0.0", @@ -10962,7 +10891,6 @@ "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-1.0.0.tgz", "integrity": "sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==", "dev": true, - "peer": true, "engines": { "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } @@ -11381,6 +11309,7 @@ }, "node_modules/npm/node_modules/@isaacs/balanced-match": { "version": "4.0.1", + "dev": true, "inBundle": true, "license": "MIT", "engines": { @@ -11389,6 +11318,7 @@ }, "node_modules/npm/node_modules/@isaacs/brace-expansion": { "version": "5.0.0", + "dev": true, "inBundle": true, "license": "MIT", "dependencies": { @@ -11400,6 +11330,7 @@ }, "node_modules/npm/node_modules/@isaacs/cliui": { "version": "8.0.2", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -11416,6 +11347,7 @@ }, "node_modules/npm/node_modules/@isaacs/cliui/node_modules/ansi-regex": { "version": "6.2.2", + "dev": true, "inBundle": true, "license": "MIT", "engines": { @@ -11427,11 +11359,13 @@ }, "node_modules/npm/node_modules/@isaacs/cliui/node_modules/emoji-regex": { "version": "9.2.2", + "dev": true, "inBundle": true, "license": "MIT" }, "node_modules/npm/node_modules/@isaacs/cliui/node_modules/string-width": { "version": "5.1.2", + "dev": true, "inBundle": true, "license": "MIT", "dependencies": { @@ -11448,6 +11382,7 @@ }, "node_modules/npm/node_modules/@isaacs/cliui/node_modules/strip-ansi": { "version": "7.1.2", + "dev": true, "inBundle": true, "license": "MIT", "dependencies": { @@ -11462,6 +11397,7 @@ }, "node_modules/npm/node_modules/@isaacs/fs-minipass": { "version": "4.0.1", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -11473,11 +11409,13 @@ }, "node_modules/npm/node_modules/@isaacs/string-locale-compare": { "version": "1.1.0", + "dev": true, "inBundle": true, "license": "ISC" }, "node_modules/npm/node_modules/@npmcli/agent": { "version": "4.0.0", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -11493,6 +11431,7 @@ }, "node_modules/npm/node_modules/@npmcli/arborist": { "version": "9.1.6", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -11539,6 +11478,7 @@ }, "node_modules/npm/node_modules/@npmcli/config": { "version": "10.4.2", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -11557,6 +11497,7 @@ }, "node_modules/npm/node_modules/@npmcli/fs": { "version": "4.0.0", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -11568,6 +11509,7 @@ }, "node_modules/npm/node_modules/@npmcli/git": { "version": "7.0.0", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -11586,6 +11528,7 @@ }, "node_modules/npm/node_modules/@npmcli/installed-package-contents": { "version": "3.0.0", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -11601,6 +11544,7 @@ }, "node_modules/npm/node_modules/@npmcli/map-workspaces": { "version": "5.0.0", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -11615,6 +11559,7 @@ }, "node_modules/npm/node_modules/@npmcli/metavuln-calculator": { "version": "9.0.2", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -11630,6 +11575,7 @@ }, "node_modules/npm/node_modules/@npmcli/name-from-folder": { "version": "3.0.0", + "dev": true, "inBundle": true, "license": "ISC", "engines": { @@ -11638,6 +11584,7 @@ }, "node_modules/npm/node_modules/@npmcli/node-gyp": { "version": "4.0.0", + "dev": true, "inBundle": true, "license": "ISC", "engines": { @@ -11646,6 +11593,7 @@ }, "node_modules/npm/node_modules/@npmcli/package-json": { "version": "7.0.1", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -11663,6 +11611,7 @@ }, "node_modules/npm/node_modules/@npmcli/promise-spawn": { "version": "8.0.3", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -11674,6 +11623,7 @@ }, "node_modules/npm/node_modules/@npmcli/query": { "version": "4.0.1", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -11685,6 +11635,7 @@ }, "node_modules/npm/node_modules/@npmcli/redact": { "version": "3.2.2", + "dev": true, "inBundle": true, "license": "ISC", "engines": { @@ -11693,6 +11644,7 @@ }, "node_modules/npm/node_modules/@npmcli/run-script": { "version": "10.0.0", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -11709,6 +11661,7 @@ }, "node_modules/npm/node_modules/@pkgjs/parseargs": { "version": "0.11.0", + "dev": true, "inBundle": true, "license": "MIT", "optional": true, @@ -11718,6 +11671,7 @@ }, "node_modules/npm/node_modules/@sigstore/bundle": { "version": "4.0.0", + "dev": true, "inBundle": true, "license": "Apache-2.0", "dependencies": { @@ -11729,6 +11683,7 @@ }, "node_modules/npm/node_modules/@sigstore/core": { "version": "3.0.0", + "dev": true, "inBundle": true, "license": "Apache-2.0", "engines": { @@ -11737,6 +11692,7 @@ }, "node_modules/npm/node_modules/@sigstore/protobuf-specs": { "version": "0.5.0", + "dev": true, "inBundle": true, "license": "Apache-2.0", "engines": { @@ -11745,6 +11701,7 @@ }, "node_modules/npm/node_modules/@sigstore/sign": { "version": "4.0.1", + "dev": true, "inBundle": true, "license": "Apache-2.0", "dependencies": { @@ -11761,6 +11718,7 @@ }, "node_modules/npm/node_modules/@sigstore/tuf": { "version": "4.0.0", + "dev": true, "inBundle": true, "license": "Apache-2.0", "dependencies": { @@ -11773,6 +11731,7 @@ }, "node_modules/npm/node_modules/@sigstore/verify": { "version": "3.0.0", + "dev": true, "inBundle": true, "license": "Apache-2.0", "dependencies": { @@ -11786,6 +11745,7 @@ }, "node_modules/npm/node_modules/@tufjs/canonical-json": { "version": "2.0.0", + "dev": true, "inBundle": true, "license": "MIT", "engines": { @@ -11794,6 +11754,7 @@ }, "node_modules/npm/node_modules/@tufjs/models": { "version": "4.0.0", + "dev": true, "inBundle": true, "license": "MIT", "dependencies": { @@ -11806,6 +11767,7 @@ }, "node_modules/npm/node_modules/@tufjs/models/node_modules/minimatch": { "version": "9.0.5", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -11820,6 +11782,7 @@ }, "node_modules/npm/node_modules/abbrev": { "version": "3.0.1", + "dev": true, "inBundle": true, "license": "ISC", "engines": { @@ -11828,6 +11791,7 @@ }, "node_modules/npm/node_modules/agent-base": { "version": "7.1.4", + "dev": true, "inBundle": true, "license": "MIT", "engines": { @@ -11836,6 +11800,7 @@ }, "node_modules/npm/node_modules/ansi-regex": { "version": "5.0.1", + "dev": true, "inBundle": true, "license": "MIT", "engines": { @@ -11844,6 +11809,7 @@ }, "node_modules/npm/node_modules/ansi-styles": { "version": "6.2.3", + "dev": true, "inBundle": true, "license": "MIT", "engines": { @@ -11855,21 +11821,25 @@ }, "node_modules/npm/node_modules/aproba": { "version": "2.1.0", + "dev": true, "inBundle": true, "license": "ISC" }, "node_modules/npm/node_modules/archy": { "version": "1.0.0", + "dev": true, "inBundle": true, "license": "MIT" }, "node_modules/npm/node_modules/balanced-match": { "version": "1.0.2", + "dev": true, "inBundle": true, "license": "MIT" }, "node_modules/npm/node_modules/bin-links": { "version": "5.0.0", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -11885,6 +11855,7 @@ }, "node_modules/npm/node_modules/binary-extensions": { "version": "3.1.0", + "dev": true, "inBundle": true, "license": "MIT", "engines": { @@ -11896,6 +11867,7 @@ }, "node_modules/npm/node_modules/brace-expansion": { "version": "2.0.2", + "dev": true, "inBundle": true, "license": "MIT", "dependencies": { @@ -11904,6 +11876,7 @@ }, "node_modules/npm/node_modules/cacache": { "version": "20.0.1", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -11925,6 +11898,7 @@ }, "node_modules/npm/node_modules/chalk": { "version": "5.6.2", + "dev": true, "inBundle": true, "license": "MIT", "engines": { @@ -11936,6 +11910,7 @@ }, "node_modules/npm/node_modules/chownr": { "version": "3.0.0", + "dev": true, "inBundle": true, "license": "BlueOak-1.0.0", "engines": { @@ -11944,6 +11919,7 @@ }, "node_modules/npm/node_modules/ci-info": { "version": "4.3.1", + "dev": true, "funding": [ { "type": "github", @@ -11958,6 +11934,7 @@ }, "node_modules/npm/node_modules/cidr-regex": { "version": "5.0.1", + "dev": true, "inBundle": true, "license": "BSD-2-Clause", "dependencies": { @@ -11969,6 +11946,7 @@ }, "node_modules/npm/node_modules/cli-columns": { "version": "4.0.0", + "dev": true, "inBundle": true, "license": "MIT", "dependencies": { @@ -11981,6 +11959,7 @@ }, "node_modules/npm/node_modules/cmd-shim": { "version": "7.0.0", + "dev": true, "inBundle": true, "license": "ISC", "engines": { @@ -11989,6 +11968,7 @@ }, "node_modules/npm/node_modules/color-convert": { "version": "2.0.1", + "dev": true, "inBundle": true, "license": "MIT", "dependencies": { @@ -12000,16 +11980,19 @@ }, "node_modules/npm/node_modules/color-name": { "version": "1.1.4", + "dev": true, "inBundle": true, "license": "MIT" }, "node_modules/npm/node_modules/common-ancestor-path": { "version": "1.0.1", + "dev": true, "inBundle": true, "license": "ISC" }, "node_modules/npm/node_modules/cross-spawn": { "version": "7.0.6", + "dev": true, "inBundle": true, "license": "MIT", "dependencies": { @@ -12023,11 +12006,13 @@ }, "node_modules/npm/node_modules/cross-spawn/node_modules/isexe": { "version": "2.0.0", + "dev": true, "inBundle": true, "license": "ISC" }, "node_modules/npm/node_modules/cross-spawn/node_modules/which": { "version": "2.0.2", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -12042,6 +12027,7 @@ }, "node_modules/npm/node_modules/cssesc": { "version": "3.0.0", + "dev": true, "inBundle": true, "license": "MIT", "bin": { @@ -12053,6 +12039,7 @@ }, "node_modules/npm/node_modules/debug": { "version": "4.4.3", + "dev": true, "inBundle": true, "license": "MIT", "dependencies": { @@ -12069,6 +12056,7 @@ }, "node_modules/npm/node_modules/diff": { "version": "8.0.2", + "dev": true, "inBundle": true, "license": "BSD-3-Clause", "engines": { @@ -12077,16 +12065,19 @@ }, "node_modules/npm/node_modules/eastasianwidth": { "version": "0.2.0", + "dev": true, "inBundle": true, "license": "MIT" }, "node_modules/npm/node_modules/emoji-regex": { "version": "8.0.0", + "dev": true, "inBundle": true, "license": "MIT" }, "node_modules/npm/node_modules/encoding": { "version": "0.1.13", + "dev": true, "inBundle": true, "license": "MIT", "optional": true, @@ -12096,6 +12087,7 @@ }, "node_modules/npm/node_modules/env-paths": { "version": "2.2.1", + "dev": true, "inBundle": true, "license": "MIT", "engines": { @@ -12104,16 +12096,19 @@ }, "node_modules/npm/node_modules/err-code": { "version": "2.0.3", + "dev": true, "inBundle": true, "license": "MIT" }, "node_modules/npm/node_modules/exponential-backoff": { "version": "3.1.2", + "dev": true, "inBundle": true, "license": "Apache-2.0" }, "node_modules/npm/node_modules/fastest-levenshtein": { "version": "1.0.16", + "dev": true, "inBundle": true, "license": "MIT", "engines": { @@ -12122,6 +12117,7 @@ }, "node_modules/npm/node_modules/foreground-child": { "version": "3.3.1", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -12137,6 +12133,7 @@ }, "node_modules/npm/node_modules/fs-minipass": { "version": "3.0.3", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -12148,6 +12145,7 @@ }, "node_modules/npm/node_modules/glob": { "version": "11.0.3", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -12170,11 +12168,13 @@ }, "node_modules/npm/node_modules/graceful-fs": { "version": "4.2.11", + "dev": true, "inBundle": true, "license": "ISC" }, "node_modules/npm/node_modules/hosted-git-info": { "version": "9.0.2", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -12186,11 +12186,13 @@ }, "node_modules/npm/node_modules/http-cache-semantics": { "version": "4.2.0", + "dev": true, "inBundle": true, "license": "BSD-2-Clause" }, "node_modules/npm/node_modules/http-proxy-agent": { "version": "7.0.2", + "dev": true, "inBundle": true, "license": "MIT", "dependencies": { @@ -12203,6 +12205,7 @@ }, "node_modules/npm/node_modules/https-proxy-agent": { "version": "7.0.6", + "dev": true, "inBundle": true, "license": "MIT", "dependencies": { @@ -12215,6 +12218,7 @@ }, "node_modules/npm/node_modules/iconv-lite": { "version": "0.6.3", + "dev": true, "inBundle": true, "license": "MIT", "optional": true, @@ -12227,6 +12231,7 @@ }, "node_modules/npm/node_modules/ignore-walk": { "version": "8.0.0", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -12238,6 +12243,7 @@ }, "node_modules/npm/node_modules/imurmurhash": { "version": "0.1.4", + "dev": true, "inBundle": true, "license": "MIT", "engines": { @@ -12246,6 +12252,7 @@ }, "node_modules/npm/node_modules/ini": { "version": "5.0.0", + "dev": true, "inBundle": true, "license": "ISC", "engines": { @@ -12254,6 +12261,7 @@ }, "node_modules/npm/node_modules/init-package-json": { "version": "8.2.2", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -12271,6 +12279,7 @@ }, "node_modules/npm/node_modules/ip-address": { "version": "10.0.1", + "dev": true, "inBundle": true, "license": "MIT", "engines": { @@ -12279,6 +12288,7 @@ }, "node_modules/npm/node_modules/ip-regex": { "version": "5.0.0", + "dev": true, "inBundle": true, "license": "MIT", "engines": { @@ -12290,6 +12300,7 @@ }, "node_modules/npm/node_modules/is-cidr": { "version": "6.0.1", + "dev": true, "inBundle": true, "license": "BSD-2-Clause", "dependencies": { @@ -12301,6 +12312,7 @@ }, "node_modules/npm/node_modules/is-fullwidth-code-point": { "version": "3.0.0", + "dev": true, "inBundle": true, "license": "MIT", "engines": { @@ -12309,6 +12321,7 @@ }, "node_modules/npm/node_modules/isexe": { "version": "3.1.1", + "dev": true, "inBundle": true, "license": "ISC", "engines": { @@ -12317,6 +12330,7 @@ }, "node_modules/npm/node_modules/jackspeak": { "version": "4.1.1", + "dev": true, "inBundle": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -12331,6 +12345,7 @@ }, "node_modules/npm/node_modules/json-parse-even-better-errors": { "version": "4.0.0", + "dev": true, "inBundle": true, "license": "MIT", "engines": { @@ -12339,6 +12354,7 @@ }, "node_modules/npm/node_modules/json-stringify-nice": { "version": "1.1.4", + "dev": true, "inBundle": true, "license": "ISC", "funding": { @@ -12347,6 +12363,7 @@ }, "node_modules/npm/node_modules/jsonparse": { "version": "1.3.1", + "dev": true, "engines": [ "node >= 0.2.0" ], @@ -12355,16 +12372,19 @@ }, "node_modules/npm/node_modules/just-diff": { "version": "6.0.2", + "dev": true, "inBundle": true, "license": "MIT" }, "node_modules/npm/node_modules/just-diff-apply": { "version": "5.5.0", + "dev": true, "inBundle": true, "license": "MIT" }, "node_modules/npm/node_modules/libnpmaccess": { "version": "10.0.3", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -12377,6 +12397,7 @@ }, "node_modules/npm/node_modules/libnpmdiff": { "version": "8.0.9", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -12395,6 +12416,7 @@ }, "node_modules/npm/node_modules/libnpmexec": { "version": "10.1.8", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -12417,6 +12439,7 @@ }, "node_modules/npm/node_modules/libnpmfund": { "version": "7.0.9", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -12428,6 +12451,7 @@ }, "node_modules/npm/node_modules/libnpmorg": { "version": "8.0.1", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -12440,6 +12464,7 @@ }, "node_modules/npm/node_modules/libnpmpack": { "version": "9.0.9", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -12454,6 +12479,7 @@ }, "node_modules/npm/node_modules/libnpmpublish": { "version": "11.1.2", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -12472,6 +12498,7 @@ }, "node_modules/npm/node_modules/libnpmsearch": { "version": "9.0.1", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -12483,6 +12510,7 @@ }, "node_modules/npm/node_modules/libnpmteam": { "version": "8.0.2", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -12495,6 +12523,7 @@ }, "node_modules/npm/node_modules/libnpmversion": { "version": "8.0.2", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -12510,6 +12539,7 @@ }, "node_modules/npm/node_modules/lru-cache": { "version": "11.2.2", + "dev": true, "inBundle": true, "license": "ISC", "engines": { @@ -12518,6 +12548,7 @@ }, "node_modules/npm/node_modules/make-fetch-happen": { "version": "15.0.2", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -12539,6 +12570,7 @@ }, "node_modules/npm/node_modules/minimatch": { "version": "10.0.3", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -12553,6 +12585,7 @@ }, "node_modules/npm/node_modules/minipass": { "version": "7.1.2", + "dev": true, "inBundle": true, "license": "ISC", "engines": { @@ -12561,6 +12594,7 @@ }, "node_modules/npm/node_modules/minipass-collect": { "version": "2.0.1", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -12572,6 +12606,7 @@ }, "node_modules/npm/node_modules/minipass-fetch": { "version": "4.0.1", + "dev": true, "inBundle": true, "license": "MIT", "dependencies": { @@ -12588,6 +12623,7 @@ }, "node_modules/npm/node_modules/minipass-flush": { "version": "1.0.5", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -12599,6 +12635,7 @@ }, "node_modules/npm/node_modules/minipass-flush/node_modules/minipass": { "version": "3.3.6", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -12610,6 +12647,7 @@ }, "node_modules/npm/node_modules/minipass-pipeline": { "version": "1.2.4", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -12621,6 +12659,7 @@ }, "node_modules/npm/node_modules/minipass-pipeline/node_modules/minipass": { "version": "3.3.6", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -12632,6 +12671,7 @@ }, "node_modules/npm/node_modules/minipass-sized": { "version": "1.0.3", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -12643,6 +12683,7 @@ }, "node_modules/npm/node_modules/minipass-sized/node_modules/minipass": { "version": "3.3.6", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -12654,6 +12695,7 @@ }, "node_modules/npm/node_modules/minizlib": { "version": "3.1.0", + "dev": true, "inBundle": true, "license": "MIT", "dependencies": { @@ -12665,11 +12707,13 @@ }, "node_modules/npm/node_modules/ms": { "version": "2.1.3", + "dev": true, "inBundle": true, "license": "MIT" }, "node_modules/npm/node_modules/mute-stream": { "version": "2.0.0", + "dev": true, "inBundle": true, "license": "ISC", "engines": { @@ -12678,6 +12722,7 @@ }, "node_modules/npm/node_modules/negotiator": { "version": "1.0.0", + "dev": true, "inBundle": true, "license": "MIT", "engines": { @@ -12686,6 +12731,7 @@ }, "node_modules/npm/node_modules/node-gyp": { "version": "11.4.2", + "dev": true, "inBundle": true, "license": "MIT", "dependencies": { @@ -12709,6 +12755,7 @@ }, "node_modules/npm/node_modules/node-gyp/node_modules/@npmcli/agent": { "version": "3.0.0", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -12724,6 +12771,7 @@ }, "node_modules/npm/node_modules/node-gyp/node_modules/cacache": { "version": "19.0.1", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -12746,6 +12794,7 @@ }, "node_modules/npm/node_modules/node-gyp/node_modules/glob": { "version": "10.4.5", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -12765,6 +12814,7 @@ }, "node_modules/npm/node_modules/node-gyp/node_modules/jackspeak": { "version": "3.4.3", + "dev": true, "inBundle": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -12779,11 +12829,13 @@ }, "node_modules/npm/node_modules/node-gyp/node_modules/lru-cache": { "version": "10.4.3", + "dev": true, "inBundle": true, "license": "ISC" }, "node_modules/npm/node_modules/node-gyp/node_modules/make-fetch-happen": { "version": "14.0.3", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -12805,6 +12857,7 @@ }, "node_modules/npm/node_modules/node-gyp/node_modules/minimatch": { "version": "9.0.5", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -12819,6 +12872,7 @@ }, "node_modules/npm/node_modules/node-gyp/node_modules/path-scurry": { "version": "1.11.1", + "dev": true, "inBundle": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -12834,6 +12888,7 @@ }, "node_modules/npm/node_modules/nopt": { "version": "8.1.0", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -12848,6 +12903,7 @@ }, "node_modules/npm/node_modules/npm-audit-report": { "version": "6.0.0", + "dev": true, "inBundle": true, "license": "ISC", "engines": { @@ -12856,6 +12912,7 @@ }, "node_modules/npm/node_modules/npm-bundled": { "version": "4.0.0", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -12867,6 +12924,7 @@ }, "node_modules/npm/node_modules/npm-install-checks": { "version": "7.1.2", + "dev": true, "inBundle": true, "license": "BSD-2-Clause", "dependencies": { @@ -12878,6 +12936,7 @@ }, "node_modules/npm/node_modules/npm-normalize-package-bin": { "version": "4.0.0", + "dev": true, "inBundle": true, "license": "ISC", "engines": { @@ -12886,6 +12945,7 @@ }, "node_modules/npm/node_modules/npm-package-arg": { "version": "13.0.1", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -12900,6 +12960,7 @@ }, "node_modules/npm/node_modules/npm-packlist": { "version": "10.0.2", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -12912,6 +12973,7 @@ }, "node_modules/npm/node_modules/npm-pick-manifest": { "version": "11.0.1", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -12926,6 +12988,7 @@ }, "node_modules/npm/node_modules/npm-profile": { "version": "12.0.0", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -12938,6 +13001,7 @@ }, "node_modules/npm/node_modules/npm-registry-fetch": { "version": "19.0.0", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -12956,6 +13020,7 @@ }, "node_modules/npm/node_modules/npm-user-validate": { "version": "3.0.0", + "dev": true, "inBundle": true, "license": "BSD-2-Clause", "engines": { @@ -12964,6 +13029,7 @@ }, "node_modules/npm/node_modules/p-map": { "version": "7.0.3", + "dev": true, "inBundle": true, "license": "MIT", "engines": { @@ -12975,11 +13041,13 @@ }, "node_modules/npm/node_modules/package-json-from-dist": { "version": "1.0.1", + "dev": true, "inBundle": true, "license": "BlueOak-1.0.0" }, "node_modules/npm/node_modules/pacote": { "version": "21.0.3", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -13010,6 +13078,7 @@ }, "node_modules/npm/node_modules/parse-conflict-json": { "version": "4.0.0", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -13023,6 +13092,7 @@ }, "node_modules/npm/node_modules/path-key": { "version": "3.1.1", + "dev": true, "inBundle": true, "license": "MIT", "engines": { @@ -13031,6 +13101,7 @@ }, "node_modules/npm/node_modules/path-scurry": { "version": "2.0.0", + "dev": true, "inBundle": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -13046,6 +13117,7 @@ }, "node_modules/npm/node_modules/postcss-selector-parser": { "version": "7.1.0", + "dev": true, "inBundle": true, "license": "MIT", "dependencies": { @@ -13058,6 +13130,7 @@ }, "node_modules/npm/node_modules/proc-log": { "version": "5.0.0", + "dev": true, "inBundle": true, "license": "ISC", "engines": { @@ -13066,6 +13139,7 @@ }, "node_modules/npm/node_modules/proggy": { "version": "3.0.0", + "dev": true, "inBundle": true, "license": "ISC", "engines": { @@ -13074,6 +13148,7 @@ }, "node_modules/npm/node_modules/promise-all-reject-late": { "version": "1.0.1", + "dev": true, "inBundle": true, "license": "ISC", "funding": { @@ -13082,6 +13157,7 @@ }, "node_modules/npm/node_modules/promise-call-limit": { "version": "3.0.2", + "dev": true, "inBundle": true, "license": "ISC", "funding": { @@ -13090,6 +13166,7 @@ }, "node_modules/npm/node_modules/promise-retry": { "version": "2.0.1", + "dev": true, "inBundle": true, "license": "MIT", "dependencies": { @@ -13102,6 +13179,7 @@ }, "node_modules/npm/node_modules/promzard": { "version": "2.0.0", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -13113,6 +13191,7 @@ }, "node_modules/npm/node_modules/qrcode-terminal": { "version": "0.12.0", + "dev": true, "inBundle": true, "bin": { "qrcode-terminal": "bin/qrcode-terminal.js" @@ -13120,6 +13199,7 @@ }, "node_modules/npm/node_modules/read": { "version": "4.1.0", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -13131,6 +13211,7 @@ }, "node_modules/npm/node_modules/read-cmd-shim": { "version": "5.0.0", + "dev": true, "inBundle": true, "license": "ISC", "engines": { @@ -13139,6 +13220,7 @@ }, "node_modules/npm/node_modules/retry": { "version": "0.12.0", + "dev": true, "inBundle": true, "license": "MIT", "engines": { @@ -13147,12 +13229,14 @@ }, "node_modules/npm/node_modules/safer-buffer": { "version": "2.1.2", + "dev": true, "inBundle": true, "license": "MIT", "optional": true }, "node_modules/npm/node_modules/semver": { "version": "7.7.3", + "dev": true, "inBundle": true, "license": "ISC", "bin": { @@ -13164,6 +13248,7 @@ }, "node_modules/npm/node_modules/shebang-command": { "version": "2.0.0", + "dev": true, "inBundle": true, "license": "MIT", "dependencies": { @@ -13175,6 +13260,7 @@ }, "node_modules/npm/node_modules/shebang-regex": { "version": "3.0.0", + "dev": true, "inBundle": true, "license": "MIT", "engines": { @@ -13183,6 +13269,7 @@ }, "node_modules/npm/node_modules/signal-exit": { "version": "4.1.0", + "dev": true, "inBundle": true, "license": "ISC", "engines": { @@ -13194,6 +13281,7 @@ }, "node_modules/npm/node_modules/sigstore": { "version": "4.0.0", + "dev": true, "inBundle": true, "license": "Apache-2.0", "dependencies": { @@ -13210,6 +13298,7 @@ }, "node_modules/npm/node_modules/smart-buffer": { "version": "4.2.0", + "dev": true, "inBundle": true, "license": "MIT", "engines": { @@ -13219,6 +13308,7 @@ }, "node_modules/npm/node_modules/socks": { "version": "2.8.7", + "dev": true, "inBundle": true, "license": "MIT", "dependencies": { @@ -13232,6 +13322,7 @@ }, "node_modules/npm/node_modules/socks-proxy-agent": { "version": "8.0.5", + "dev": true, "inBundle": true, "license": "MIT", "dependencies": { @@ -13245,6 +13336,7 @@ }, "node_modules/npm/node_modules/spdx-correct": { "version": "3.2.0", + "dev": true, "inBundle": true, "license": "Apache-2.0", "dependencies": { @@ -13254,6 +13346,7 @@ }, "node_modules/npm/node_modules/spdx-correct/node_modules/spdx-expression-parse": { "version": "3.0.1", + "dev": true, "inBundle": true, "license": "MIT", "dependencies": { @@ -13263,11 +13356,13 @@ }, "node_modules/npm/node_modules/spdx-exceptions": { "version": "2.5.0", + "dev": true, "inBundle": true, "license": "CC-BY-3.0" }, "node_modules/npm/node_modules/spdx-expression-parse": { "version": "4.0.0", + "dev": true, "inBundle": true, "license": "MIT", "dependencies": { @@ -13277,11 +13372,13 @@ }, "node_modules/npm/node_modules/spdx-license-ids": { "version": "3.0.22", + "dev": true, "inBundle": true, "license": "CC0-1.0" }, "node_modules/npm/node_modules/ssri": { "version": "12.0.0", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -13293,6 +13390,7 @@ }, "node_modules/npm/node_modules/string-width": { "version": "4.2.3", + "dev": true, "inBundle": true, "license": "MIT", "dependencies": { @@ -13307,6 +13405,7 @@ "node_modules/npm/node_modules/string-width-cjs": { "name": "string-width", "version": "4.2.3", + "dev": true, "inBundle": true, "license": "MIT", "dependencies": { @@ -13320,6 +13419,7 @@ }, "node_modules/npm/node_modules/strip-ansi": { "version": "6.0.1", + "dev": true, "inBundle": true, "license": "MIT", "dependencies": { @@ -13332,6 +13432,7 @@ "node_modules/npm/node_modules/strip-ansi-cjs": { "name": "strip-ansi", "version": "6.0.1", + "dev": true, "inBundle": true, "license": "MIT", "dependencies": { @@ -13343,6 +13444,7 @@ }, "node_modules/npm/node_modules/supports-color": { "version": "10.2.2", + "dev": true, "inBundle": true, "license": "MIT", "engines": { @@ -13354,6 +13456,7 @@ }, "node_modules/npm/node_modules/tar": { "version": "7.5.1", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -13369,6 +13472,7 @@ }, "node_modules/npm/node_modules/tar/node_modules/yallist": { "version": "5.0.0", + "dev": true, "inBundle": true, "license": "BlueOak-1.0.0", "engines": { @@ -13377,16 +13481,19 @@ }, "node_modules/npm/node_modules/text-table": { "version": "0.2.0", + "dev": true, "inBundle": true, "license": "MIT" }, "node_modules/npm/node_modules/tiny-relative-date": { "version": "2.0.2", + "dev": true, "inBundle": true, "license": "MIT" }, "node_modules/npm/node_modules/tinyglobby": { "version": "0.2.15", + "dev": true, "inBundle": true, "license": "MIT", "dependencies": { @@ -13402,6 +13509,7 @@ }, "node_modules/npm/node_modules/tinyglobby/node_modules/fdir": { "version": "6.5.0", + "dev": true, "inBundle": true, "license": "MIT", "engines": { @@ -13418,8 +13526,10 @@ }, "node_modules/npm/node_modules/tinyglobby/node_modules/picomatch": { "version": "4.0.3", + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -13429,6 +13539,7 @@ }, "node_modules/npm/node_modules/treeverse": { "version": "3.0.0", + "dev": true, "inBundle": true, "license": "ISC", "engines": { @@ -13437,6 +13548,7 @@ }, "node_modules/npm/node_modules/tuf-js": { "version": "4.0.0", + "dev": true, "inBundle": true, "license": "MIT", "dependencies": { @@ -13450,6 +13562,7 @@ }, "node_modules/npm/node_modules/unique-filename": { "version": "4.0.0", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -13461,6 +13574,7 @@ }, "node_modules/npm/node_modules/unique-slug": { "version": "5.0.0", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -13472,11 +13586,13 @@ }, "node_modules/npm/node_modules/util-deprecate": { "version": "1.0.2", + "dev": true, "inBundle": true, "license": "MIT" }, "node_modules/npm/node_modules/validate-npm-package-license": { "version": "3.0.4", + "dev": true, "inBundle": true, "license": "Apache-2.0", "dependencies": { @@ -13486,6 +13602,7 @@ }, "node_modules/npm/node_modules/validate-npm-package-license/node_modules/spdx-expression-parse": { "version": "3.0.1", + "dev": true, "inBundle": true, "license": "MIT", "dependencies": { @@ -13495,6 +13612,7 @@ }, "node_modules/npm/node_modules/validate-npm-package-name": { "version": "6.0.2", + "dev": true, "inBundle": true, "license": "ISC", "engines": { @@ -13503,6 +13621,7 @@ }, "node_modules/npm/node_modules/walk-up-path": { "version": "4.0.0", + "dev": true, "inBundle": true, "license": "ISC", "engines": { @@ -13511,6 +13630,7 @@ }, "node_modules/npm/node_modules/which": { "version": "5.0.0", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -13525,6 +13645,7 @@ }, "node_modules/npm/node_modules/wrap-ansi": { "version": "8.1.0", + "dev": true, "inBundle": true, "license": "MIT", "dependencies": { @@ -13542,6 +13663,7 @@ "node_modules/npm/node_modules/wrap-ansi-cjs": { "name": "wrap-ansi", "version": "7.0.0", + "dev": true, "inBundle": true, "license": "MIT", "dependencies": { @@ -13558,6 +13680,7 @@ }, "node_modules/npm/node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { "version": "4.3.0", + "dev": true, "inBundle": true, "license": "MIT", "dependencies": { @@ -13572,6 +13695,7 @@ }, "node_modules/npm/node_modules/wrap-ansi/node_modules/ansi-regex": { "version": "6.2.2", + "dev": true, "inBundle": true, "license": "MIT", "engines": { @@ -13583,11 +13707,13 @@ }, "node_modules/npm/node_modules/wrap-ansi/node_modules/emoji-regex": { "version": "9.2.2", + "dev": true, "inBundle": true, "license": "MIT" }, "node_modules/npm/node_modules/wrap-ansi/node_modules/string-width": { "version": "5.1.2", + "dev": true, "inBundle": true, "license": "MIT", "dependencies": { @@ -13604,6 +13730,7 @@ }, "node_modules/npm/node_modules/wrap-ansi/node_modules/strip-ansi": { "version": "7.1.2", + "dev": true, "inBundle": true, "license": "MIT", "dependencies": { @@ -13618,6 +13745,7 @@ }, "node_modules/npm/node_modules/write-file-atomic": { "version": "6.0.0", + "dev": true, "inBundle": true, "license": "ISC", "dependencies": { @@ -13630,6 +13758,7 @@ }, "node_modules/npm/node_modules/yallist": { "version": "4.0.0", + "dev": true, "inBundle": true, "license": "ISC" }, @@ -13810,7 +13939,6 @@ "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", "dev": true, - "peer": true, "dependencies": { "bl": "^4.1.0", "chalk": "^4.1.0", @@ -13834,7 +13962,6 @@ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, - "peer": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -13851,7 +13978,6 @@ "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==", "dev": true, - "peer": true, "engines": { "node": ">=0.10.0" } @@ -14024,7 +14150,6 @@ "resolved": "https://registry.npmjs.org/parse-passwd/-/parse-passwd-1.0.0.tgz", "integrity": "sha512-1Y1A//QUXEZK7YKz+rD9WydcE1+EuPr6ZBgKecAB8tmoW6UFv0NREVJe1p+jRxtThkcbbKkfwIbWJe/IeE6m2Q==", "dev": true, - "peer": true, "engines": { "node": ">=0.10.0" } @@ -14331,6 +14456,7 @@ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz", "integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==", "dev": true, + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -14971,7 +15097,6 @@ "resolved": "https://registry.npmjs.org/resolve-dir/-/resolve-dir-1.0.1.tgz", "integrity": "sha512-R7uiTjECzvOsWSfdM0QKFNBVFcK27aHOUwdvK53BcW8zqnGdYp0Fbj82cy54+2A4P2tFM22J5kRfe1R+lM/1yg==", "dev": true, - "peer": true, "dependencies": { "expand-tilde": "^2.0.0", "global-modules": "^1.0.0" @@ -15003,7 +15128,6 @@ "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", "dev": true, - "peer": true, "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" @@ -15091,7 +15215,6 @@ "resolved": "https://registry.npmjs.org/run-async/-/run-async-3.0.0.tgz", "integrity": "sha512-540WwVDOMxA6dN6We19EcT9sc3hkXPw5mzRNGM3FkdN/vtE9NFvj5lFAPNwUDmJjXidm3v7TC1cTE7t17Ulm1Q==", "dev": true, - "peer": true, "engines": { "node": ">=0.12.0" } @@ -15124,7 +15247,6 @@ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", "dev": true, - "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -15214,6 +15336,7 @@ "integrity": "sha512-6qGjWccl5yoyugHt3jTgztJ9Y0JVzyH8/Voc/D8PlLat9pwxQYXz7W1Dpnq5h0/G5GCYGUaDSlYcyk3AMh5A6g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/error": "^4.0.0", @@ -16233,7 +16356,6 @@ "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", "dev": true, - "peer": true, "engines": { "node": ">=8" } @@ -16627,6 +16749,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -16666,7 +16789,6 @@ "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", "dev": true, - "peer": true, "dependencies": { "os-tmpdir": "~1.0.2" }, @@ -17072,6 +17194,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-6.1.1.tgz", "integrity": "sha512-4GgM54XrwRfrOp297aIYspIti66k56v16ZnqHvrIM7mG+HjDlAwS7p+Srr7J6fGvEdOJ5JcQ/D9T7HhtdXDTzA==", "dev": true, + "peer": true, "dependencies": { "esbuild": "^0.24.2", "postcss": "^8.5.2", @@ -17165,6 +17288,7 @@ "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.0.6.tgz", "integrity": "sha512-/iL1Sc5VeDZKPDe58oGK4HUFLhw6b5XdY1MYawjuSaDA4sEfYlY9HnS6aCEG26fX+MgUi7MwlduTBHHAI/OvMA==", "dev": true, + "peer": true, "dependencies": { "@vitest/expect": "3.0.6", "@vitest/mocker": "3.0.6", @@ -17243,7 +17367,6 @@ "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", "dev": true, - "peer": true, "dependencies": { "defaults": "^1.0.3" } @@ -17462,7 +17585,6 @@ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", "dev": true, - "peer": true, "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", @@ -17598,7 +17720,6 @@ "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.2.tgz", "integrity": "sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==", "dev": true, - "peer": true, "engines": { "node": ">=18" }, diff --git a/src/core/file/infrastructure/adapter/file.store.spec.js b/src/core/file/infrastructure/adapter/file.store.spec.js index ba74015..03c5b10 100644 --- a/src/core/file/infrastructure/adapter/file.store.spec.js +++ b/src/core/file/infrastructure/adapter/file.store.spec.js @@ -1,6 +1,14 @@ -import { FileStore } from './file.store.js'; import { describe, expect, test, vi } from 'vitest'; +// Isolation : on mocke le module `fs` pour ne pas dépendre du système de fichiers +// (lecture d'un vrai `.ods`) ni écrire réellement sur le disque. +vi.mock('fs', () => ({ + readFile: vi.fn((path, cb) => cb(null, Buffer.from(new ArrayBuffer(1)))), + writeFile: vi.fn((path, content, opts, cb) => cb(null)) +})); + +const { FileStore } = await import('./file.store.js'); + global.fetch = vi.fn(() => Promise.resolve({ arrayBuffer: () => Promise.resolve(new ArrayBuffer(1)) diff --git a/src/features/dpe/domain/models/type-generateur.model.js b/src/features/dpe/domain/models/type-generateur.model.js index c96bc48..b926d95 100644 --- a/src/features/dpe/domain/models/type-generateur.model.js +++ b/src/features/dpe/domain/models/type-generateur.model.js @@ -2,7 +2,6 @@ * @type {{[key: string]: string}} */ export const TypeGenerateur = { - COMBUSTION: 'COMBUSTION', - PAC: 'PAC', + CHAUDIERE: 'CHAUDIERE', OTHER: 'OTHER' }; diff --git a/src/features/dpe/domain/models/type-generateur.model.spec.js b/src/features/dpe/domain/models/type-generateur.model.spec.js index 77a1510..51309fd 100644 --- a/src/features/dpe/domain/models/type-generateur.model.spec.js +++ b/src/features/dpe/domain/models/type-generateur.model.spec.js @@ -4,8 +4,7 @@ import { TypeGenerateur } from './type-generateur.model.js'; describe('Modèle TypeGenerateur', () => { test('expose les types de générateurs attendus', () => { expect(TypeGenerateur).toEqual({ - COMBUSTION: 'COMBUSTION', - PAC: 'PAC', + CHAUDIERE: 'CHAUDIERE', OTHER: 'OTHER' }); }); diff --git a/src/features/dpe/infrastructure/ch/chTv.store.spec.js b/src/features/dpe/infrastructure/ch/chTv.store.spec.js index 746ea33..6b04687 100755 --- a/src/features/dpe/infrastructure/ch/chTv.store.spec.js +++ b/src/features/dpe/infrastructure/ch/chTv.store.spec.js @@ -33,8 +33,8 @@ describe('Lecture des tables de valeurs', () => { enum_type_generateur_ecs_id: '46|93', type_generateur: 'Chaudière gaz classique 1981-1985', pn: 'Pn', - rpn: '84 + 2 logPn', - rpint: '80 + 3 logPn', + rpn: '84+2*log10(Pn)', + rpint: '80+3*log10(Pn)', qp0_perc: '2%', pveil: '150' }); @@ -46,8 +46,8 @@ describe('Lecture des tables de valeurs', () => { type_generateur: 'Chaudière fioul à condensation après 2015', critere_pn: '70= rAiuAue))) + ((!v.aiu_aue_min || parseAiuAueBound(v.aiu_aue_min) < rAiuAue) && + (!v.aiu_aue_max || parseAiuAueBound(v.aiu_aue_max) >= rAiuAue))) )?.b; if (!b) { diff --git a/src/features/engine/domain/apport_et_besoin/apport-et-besoin.service.spec.js b/src/features/engine/domain/apport_et_besoin/apport-et-besoin.service.spec.js index 69363d7..06cc0b5 100755 --- a/src/features/engine/domain/apport_et_besoin/apport-et-besoin.service.spec.js +++ b/src/features/engine/domain/apport_et_besoin/apport-et-besoin.service.spec.js @@ -15,6 +15,7 @@ import { PRECISION_PERCENT } from '../../../../../test/constant.js'; import { DpeNormalizerService } from '../../../normalizer/domain/dpe-normalizer.service.js'; import { ContexteBuilder } from '../contexte.builder.js'; import { InstallationChService } from '../ch/installation-ch.service.js'; +import { describeIntegration } from '../../../../../test/helpers/integration-test.js'; /** @type {SurfaceSudEquivalenteService} **/ let surfaceSudEquivalenteService; @@ -157,7 +158,7 @@ describe('Calcul des apports et besoin du logement', () => { expect(installationChService.execute).toHaveBeenCalledWith(ctx, logement); }); - describe("Test d'intégration pour les besoins en chauffage", () => { + describeIntegration("Test d'intégration pour les besoins en chauffage", () => { test.each(corpus)( 'Vérification de la DI besoin_ch des besoins en chauffage pour dpe %s', (ademeId) => { diff --git a/src/features/engine/domain/apport_et_besoin/ch/perte-ch-recup.service.spec.js b/src/features/engine/domain/apport_et_besoin/ch/perte-ch-recup.service.spec.js index bbeddc9..d3f0419 100755 --- a/src/features/engine/domain/apport_et_besoin/ch/perte-ch-recup.service.spec.js +++ b/src/features/engine/domain/apport_et_besoin/ch/perte-ch-recup.service.spec.js @@ -9,6 +9,7 @@ import { PerteChRecupService } from './perte-ch-recup.service.js'; import { BesoinChService } from './besoin-ch.service.js'; import { ApportGratuitService } from '../apport_gratuit/apport-gratuit.service.js'; import { PRECISION_PERCENT } from '../../../../../../test/constant.js'; +import { describeIntegration } from '../../../../../../test/helpers/integration-test.js'; /** @type {PerteChRecupService} **/ let service; @@ -239,7 +240,7 @@ describe('Calcul des pertes de génération de chauffage récupérées', () => { } }); - describe("Test d'intégration des installations CH", () => { + describeIntegration("Test d'intégration des installations CH", () => { test.each(corpus)('vérification des pertes de génération ch pour dpe %s', (ademeId) => { let dpeRequest = getAdemeFileJson(ademeId); dpeRequest = normalizerService.normalize(dpeRequest); diff --git a/src/features/engine/domain/apport_et_besoin/ecs/besoin-ecs.service.spec.js b/src/features/engine/domain/apport_et_besoin/ecs/besoin-ecs.service.spec.js index 9949841..966d87b 100755 --- a/src/features/engine/domain/apport_et_besoin/ecs/besoin-ecs.service.spec.js +++ b/src/features/engine/domain/apport_et_besoin/ecs/besoin-ecs.service.spec.js @@ -5,6 +5,7 @@ import { ContexteBuilder } from '../../contexte.builder.js'; import { getAdemeFileJson } from '../../../../../../test/test-helpers.js'; import { BesoinEcsService } from './besoin-ecs.service.js'; import { EcsTvStore } from '../../../../dpe/infrastructure/ecs/ecsTv.store.js'; +import { describeIntegration } from '../../../../../../test/helpers/integration-test.js'; /** @type {BesoinEcsService} **/ let service; @@ -45,7 +46,7 @@ describe('Calcul du besoin en eau chaude sanitaire', () => { expect(tvStore.getTefs).toHaveBeenCalledWith('400-800m', 'h1a', 'Janvier'); }); - describe("Test d'intégration pour le besoin en eau chaude sanitaire", () => { + describeIntegration("Test d'intégration pour le besoin en eau chaude sanitaire", () => { test.each(corpus)('vérification des sorties besoin_ecs pour dpe %s', (ademeId) => { /** * @type {Dpe} diff --git a/src/features/engine/domain/apport_et_besoin/ecs/perte-ecs-recup.service.spec.js b/src/features/engine/domain/apport_et_besoin/ecs/perte-ecs-recup.service.spec.js index f6dc26d..c8be177 100755 --- a/src/features/engine/domain/apport_et_besoin/ecs/perte-ecs-recup.service.spec.js +++ b/src/features/engine/domain/apport_et_besoin/ecs/perte-ecs-recup.service.spec.js @@ -9,6 +9,7 @@ import { mois_liste } from '../../../../../utils.js'; import { EcsTvStore } from '../../../../dpe/infrastructure/ecs/ecsTv.store.js'; import { GenerateurEcsService } from '../../ecs/generateur-ecs.service.js'; import { InstallationEcsService } from '../../ecs/installation-ecs.service.js'; +import { describeIntegration } from '../../../../../../test/helpers/integration-test.js'; /** @type {PerteEcsRecupService} **/ let service; @@ -130,7 +131,7 @@ describe('Calcul des pertes de distribution et stockage récupérées', () => { } }); - describe("Test d'intégration des installations ECS", () => { + describeIntegration("Test d'intégration des installations ECS", () => { test.each(corpus)( 'vérification des pertes de distribution ecs recup des installations ECS pour dpe %s', (ademeId) => { diff --git a/src/features/engine/domain/apport_et_besoin/froid/besoin-froid.service.spec.js b/src/features/engine/domain/apport_et_besoin/froid/besoin-froid.service.spec.js index 946f58d..90ba57e 100755 --- a/src/features/engine/domain/apport_et_besoin/froid/besoin-froid.service.spec.js +++ b/src/features/engine/domain/apport_et_besoin/froid/besoin-froid.service.spec.js @@ -6,6 +6,7 @@ import { getAdemeFileJson } from '../../../../../../test/test-helpers.js'; import { FrTvStore } from '../../../../dpe/infrastructure/froid/frTv.store.js'; import { ApportGratuitService } from '../apport_gratuit/apport-gratuit.service.js'; import { BesoinFroidService } from './besoin-froid.service.js'; +import { describeIntegration } from '../../../../../../test/helpers/integration-test.js'; /** @type {ApportGratuitService} **/ let apportGratuitService; @@ -207,7 +208,7 @@ describe('Calcul des besoins en froid du logement', () => { ); }); - describe("Test d'intégration pour le besoin en froid", () => { + describeIntegration("Test d'intégration pour le besoin en froid", () => { test.each(corpus)('vérification des sorties besoin_fr pour dpe %s', (ademeId) => { /** * @type {Dpe} diff --git a/src/features/engine/domain/apport_et_besoin/surface-sud-equivalente.service.spec.js b/src/features/engine/domain/apport_et_besoin/surface-sud-equivalente.service.spec.js index 3e5ac7d..a7c4fc1 100755 --- a/src/features/engine/domain/apport_et_besoin/surface-sud-equivalente.service.spec.js +++ b/src/features/engine/domain/apport_et_besoin/surface-sud-equivalente.service.spec.js @@ -5,6 +5,7 @@ import { ContexteBuilder } from '../contexte.builder.js'; import { SurfaceSudEquivalenteService } from './surface-sud-equivalente.service.js'; import { getAdemeFileJson } from '../../../../../test/test-helpers.js'; import { BaieVitreeTvStore } from '../../../dpe/infrastructure/enveloppe/baieVitreeTv.store.js'; +import { describeIntegration } from '../../../../../test/helpers/integration-test.js'; /** @type {SurfaceSudEquivalenteService} **/ let service; @@ -206,7 +207,7 @@ describe('Calcul de la surface sud équivalente du logement', () => { }); }); - describe("Test d'intégration pour surface sud équivalente", () => { + describeIntegration("Test d'intégration pour surface sud équivalente", () => { test.each(corpus)( 'vérification des DI de la surface sud équivalente pour dpe %s', (ademeId) => { diff --git a/src/features/engine/domain/ch/generateur-ch.service.spec.js b/src/features/engine/domain/ch/generateur-ch.service.spec.js index 3805b60..485b59f 100755 --- a/src/features/engine/domain/ch/generateur-ch.service.spec.js +++ b/src/features/engine/domain/ch/generateur-ch.service.spec.js @@ -4,11 +4,12 @@ import { GenerateurChService } from './generateur-ch.service.js'; import { DpeNormalizerService } from '../../../normalizer/domain/dpe-normalizer.service.js'; import corpus from '../../../../../test/corpus-sano.json'; import { getAdemeFileJson } from '../../../../../test/test-helpers.js'; -import { TypeGenerateur } from '../../../dpe/domain/models/installation-chauffage.model.js'; +import { TypeGenerateur } from '../../../dpe/domain/models/type-generateur.model.js'; import { ChTvStore } from '../../../dpe/infrastructure/ch/chTv.store.js'; import { ContexteBuilder } from '../contexte.builder.js'; import { TvStore } from '../../../dpe/infrastructure/tv.store.js'; import { EmetteurChService } from './emetteur-ch.service.js'; +import { describeIntegration } from '../../../../../test/helpers/integration-test.js'; /** @type {GenerateurChService} **/ let service; @@ -563,7 +564,7 @@ describe('Calcul des caractéristiques des générateurs de chauffage', () => { }); }); - describe("Test d'intégration des installations de chauffage", () => { + describeIntegration("Test d'intégration des installations de chauffage", () => { test.each(corpus)('vérification des DI qp0 des installations CH pour dpe %s', (ademeId) => { let dpeRequest = getAdemeFileJson(ademeId); dpeRequest = normalizerService.normalize(dpeRequest); diff --git a/src/features/engine/domain/conso/froid/conso-froid.service.spec.js b/src/features/engine/domain/conso/froid/conso-froid.service.spec.js index 411d519..bec3a1c 100755 --- a/src/features/engine/domain/conso/froid/conso-froid.service.spec.js +++ b/src/features/engine/domain/conso/froid/conso-froid.service.spec.js @@ -5,6 +5,7 @@ import corpus from '../../../../../../test/corpus-sano.json'; import { getAdemeFileJson } from '../../../../../../test/test-helpers.js'; import { FrTvStore } from '../../../../dpe/infrastructure/froid/frTv.store.js'; import { ConsoFroidService } from './conso-froid.service.js'; +import { describeIntegration } from '../../../../../../test/helpers/integration-test.js'; /** @type {ConsoFroidService} **/ let service; @@ -117,7 +118,7 @@ describe('Calcul des consos en froid du logement', () => { } ); - describe("Test d'intégration pour le besoin en froid", () => { + describeIntegration("Test d'intégration pour le besoin en froid", () => { test.each(corpus)('vérification des sorties besoin_fr et conso_fr pour dpe %s', (ademeId) => { /** * @type {Dpe} diff --git a/src/features/engine/domain/ecs/installation-ecs.service.spec.js b/src/features/engine/domain/ecs/installation-ecs.service.spec.js index 5b23a62..a1da85a 100755 --- a/src/features/engine/domain/ecs/installation-ecs.service.spec.js +++ b/src/features/engine/domain/ecs/installation-ecs.service.spec.js @@ -5,6 +5,7 @@ import corpus from '../../../../../test/corpus-sano.json'; import { expect_or, getAdemeFileJson } from '../../../../../test/test-helpers.js'; import { DpeNormalizerService } from '../../../normalizer/domain/dpe-normalizer.service.js'; import { ContexteBuilder } from '../contexte.builder.js'; +import { describeIntegration } from '../../../../../test/helpers/integration-test.js'; /** @type {InstallationEcsService} **/ let service; @@ -247,7 +248,7 @@ describe('Calcul des besoins et des pertes des installations ECS', () => { expect(service.isInstallationIndividuelle({ enum_type_installation_id: 4 })).toBeFalsy(); }); - describe("Test d'intégration des installations ECS", () => { + describeIntegration("Test d'intégration des installations ECS", () => { test.each(corpus)('vérification des DI des installations ECS pour dpe %s', (ademeId) => { let dpeRequest = getAdemeFileJson(ademeId); dpeRequest = normalizerService.normalize(dpeRequest); diff --git a/src/features/engine/domain/enveloppe/baie_vitree/deperdition-baie-vitree.service.spec.js b/src/features/engine/domain/enveloppe/baie_vitree/deperdition-baie-vitree.service.spec.js index f3ffdd0..6435218 100644 --- a/src/features/engine/domain/enveloppe/baie_vitree/deperdition-baie-vitree.service.spec.js +++ b/src/features/engine/domain/enveloppe/baie_vitree/deperdition-baie-vitree.service.spec.js @@ -5,6 +5,7 @@ import { DpeNormalizerService } from '../../../../normalizer/domain/dpe-normaliz import { DeperditionBaieVitreeService } from './deperdition-baie-vitree.service.js'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import { BaieVitreeTvStore } from '../../../../dpe/infrastructure/enveloppe/baieVitreeTv.store.js'; +import { describeIntegration } from '../../../../../../test/helpers/integration-test.js'; /** @type {DeperditionBaieVitreeService} **/ let service; @@ -445,7 +446,7 @@ describe('Calcul de déperdition des baies vitrées', () => { expect(bvDI.u_menuiserie).toBe(5.6); }); - describe("Test d'intégration des baies vitrées", () => { + describeIntegration("Test d'intégration des baies vitrées", () => { test.each(corpus)('vérification des DI des baies vitrées pour dpe %s', (ademeId) => { let dpeRequest = getAdemeFileJson(ademeId); dpeRequest = normalizerService.normalize(dpeRequest); diff --git a/src/features/engine/domain/enveloppe/deperdition-enveloppe.service.spec.js b/src/features/engine/domain/enveloppe/deperdition-enveloppe.service.spec.js index d08663a..4ddd913 100755 --- a/src/features/engine/domain/enveloppe/deperdition-enveloppe.service.spec.js +++ b/src/features/engine/domain/enveloppe/deperdition-enveloppe.service.spec.js @@ -10,6 +10,7 @@ import { DpeNormalizerService } from '../../../normalizer/domain/dpe-normalizer. import { TvStore } from '../../../dpe/infrastructure/tv.store.js'; import { beforeEach, describe, expect, test } from 'vitest'; import b from '../../../../3.1_b.js'; +import { describeIntegration } from '../../../../../test/helpers/integration-test.js'; /** @type {DeperditionPorteService} **/ let deperditionPorteService; @@ -249,7 +250,7 @@ describe('Calcul des déperditions', () => { }); }); - describe("Test d'intégration de calcul des deperditions", () => { + describeIntegration("Test d'intégration de calcul des deperditions", () => { test.each(corpus)('deperditions pour dpe %s', (ademeId) => { let dpeRequest = getAdemeFileJson(ademeId); dpeRequest = normalizerService.normalize(dpeRequest); diff --git a/src/features/engine/domain/enveloppe/espace_tampon/espace-tampon.service.spec.js b/src/features/engine/domain/enveloppe/espace_tampon/espace-tampon.service.spec.js index 2de6049..93ae4a9 100644 --- a/src/features/engine/domain/enveloppe/espace_tampon/espace-tampon.service.spec.js +++ b/src/features/engine/domain/enveloppe/espace_tampon/espace-tampon.service.spec.js @@ -4,6 +4,7 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; import { BaieVitreeTvStore } from '../../../../dpe/infrastructure/enveloppe/baieVitreeTv.store.js'; import { EspaceTamponService } from './espace-tampon.service.js'; import { getAdemeFileJson } from '../../../../../../test/test-helpers.js'; +import { describeIntegration } from '../../../../../../test/helpers/integration-test.js'; /** @type {EspaceTamponService} **/ let service; @@ -54,7 +55,7 @@ describe('Calcul de déperdition des baies vitrées', () => { }); }); - describe("Test d'intégration des ets", () => { + describeIntegration("Test d'intégration des ets", () => { test.each(['2273E0303205P', '2364E0984413P', '2283E0131604Y'])( 'vérification des DI des ets pour dpe %s', (ademeId) => { diff --git a/src/features/engine/domain/enveloppe/mur/deperdition-mur.service.spec.js b/src/features/engine/domain/enveloppe/mur/deperdition-mur.service.spec.js index a7e5edd..3f8e94c 100755 --- a/src/features/engine/domain/enveloppe/mur/deperdition-mur.service.spec.js +++ b/src/features/engine/domain/enveloppe/mur/deperdition-mur.service.spec.js @@ -5,6 +5,7 @@ import { ContexteBuilder } from '../../contexte.builder.js'; import { DpeNormalizerService } from '../../../../normalizer/domain/dpe-normalizer.service.js'; import { TvStore } from '../../../../dpe/infrastructure/tv.store.js'; import { beforeEach, describe, expect, test } from 'vitest'; +import { describeIntegration } from '../../../../../../test/helpers/integration-test.js'; /** @type {DeperditionMurService} **/ let service; @@ -396,7 +397,7 @@ describe('Calcul de déperdition des murs', () => { } ); - describe("Test d'intégration de mur", () => { + describeIntegration("Test d'intégration de mur", () => { test.each(corpus)('vérification des DI des murs pour dpe %s', (ademeId) => { let dpeRequest = getAdemeFileJson(ademeId); dpeRequest = normalizerService.normalize(dpeRequest); diff --git a/src/features/engine/domain/enveloppe/plancher_bas/deperdition-plancher-bas.service.spec.js b/src/features/engine/domain/enveloppe/plancher_bas/deperdition-plancher-bas.service.spec.js index 43e9d6b..2428651 100755 --- a/src/features/engine/domain/enveloppe/plancher_bas/deperdition-plancher-bas.service.spec.js +++ b/src/features/engine/domain/enveloppe/plancher_bas/deperdition-plancher-bas.service.spec.js @@ -5,6 +5,7 @@ import { DpeNormalizerService } from '../../../../normalizer/domain/dpe-normaliz import { DeperditionPlancherBasService } from './deperdition-plancher-bas.service.js'; import { TvStore } from '../../../../dpe/infrastructure/tv.store.js'; import { beforeEach, describe, expect, test } from 'vitest'; +import { describeIntegration } from '../../../../../../test/helpers/integration-test.js'; /** @type {DeperditionPlancherBasService} **/ let service; @@ -267,7 +268,7 @@ describe('Calcul de déperdition des planchers bas', () => { } ); - describe("Test d'intégration de plancher bas", () => { + describeIntegration("Test d'intégration de plancher bas", () => { test.each(corpus)('vérification des DI des pb pour dpe %s', (ademeId) => { /** * @type {Dpe} diff --git a/src/features/engine/domain/enveloppe/plancher_haut/deperdition-plancher-haut.service.spec.js b/src/features/engine/domain/enveloppe/plancher_haut/deperdition-plancher-haut.service.spec.js index edbbbc6..559a141 100755 --- a/src/features/engine/domain/enveloppe/plancher_haut/deperdition-plancher-haut.service.spec.js +++ b/src/features/engine/domain/enveloppe/plancher_haut/deperdition-plancher-haut.service.spec.js @@ -5,6 +5,7 @@ import { DeperditionPlancherHautService } from './deperdition-plancher-haut.serv import corpus from '../../../../../../test/corpus-sano.json'; import { TvStore } from '../../../../dpe/infrastructure/tv.store.js'; import { beforeEach, describe, expect, test } from 'vitest'; +import { describeIntegration } from '../../../../../../test/helpers/integration-test.js'; /** @type {DeperditionPlancherHautService} **/ let service; @@ -256,7 +257,7 @@ describe('Calcul de déperdition des planchers haut', () => { } ); - describe("Test d'intégration de plancher haut", () => { + describeIntegration("Test d'intégration de plancher haut", () => { test.each(corpus)('vérification des DI des ph pour dpe %s', (ademeId) => { /** * @type {Dpe} diff --git a/src/features/engine/domain/enveloppe/pont_thermique/deperdition-pont-thermique.service.spec.js b/src/features/engine/domain/enveloppe/pont_thermique/deperdition-pont-thermique.service.spec.js index 174f64e..21d39e7 100644 --- a/src/features/engine/domain/enveloppe/pont_thermique/deperdition-pont-thermique.service.spec.js +++ b/src/features/engine/domain/enveloppe/pont_thermique/deperdition-pont-thermique.service.spec.js @@ -8,6 +8,7 @@ import { PontThermiqueTvStore } from '../../../../dpe/infrastructure/enveloppe/p import { DeperditionMurService } from '../mur/deperdition-mur.service.js'; import { DeperditionPlancherHautService } from '../plancher_haut/deperdition-plancher-haut.service.js'; import { DeperditionPlancherBasService } from '../plancher_bas/deperdition-plancher-bas.service.js'; +import { describeIntegration } from '../../../../../../test/helpers/integration-test.js'; /** @type {DeperditionPontThermiqueService} **/ let service; @@ -616,7 +617,7 @@ describe('Calcul de déperdition des ponts thermiques', () => { }); }); - describe("Test d'intégration des ponts thermiques", () => { + describeIntegration("Test d'intégration des ponts thermiques", () => { test.each(corpus)('vérification des DI des ponts thermiques pour dpe %s', (ademeId) => { let dpeRequest = getAdemeFileJson(ademeId); dpeRequest = normalizerService.normalize(dpeRequest); diff --git a/src/features/engine/domain/enveloppe/porte/deperdition-porte.service.spec.js b/src/features/engine/domain/enveloppe/porte/deperdition-porte.service.spec.js index d3affdf..cbae5f6 100755 --- a/src/features/engine/domain/enveloppe/porte/deperdition-porte.service.spec.js +++ b/src/features/engine/domain/enveloppe/porte/deperdition-porte.service.spec.js @@ -3,6 +3,7 @@ import corpus from '../../../../../../test/corpus-sano.json'; import { getAdemeFileJson } from '../../../../../../test/test-helpers.js'; import { beforeEach, describe, expect, test } from 'vitest'; import { TvStore } from '../../../../dpe/infrastructure/tv.store.js'; +import { describeIntegration } from '../../../../../../test/helpers/integration-test.js'; /** @type {DeperditionPorteService} **/ let service; @@ -99,7 +100,7 @@ describe('Calcul de déperdition des portes', () => { }); }); - describe("Test d'intégration de porte", () => { + describeIntegration("Test d'intégration de porte", () => { test.each(corpus)('vérification des DI des portes pour dpe %s', (ademeId) => { const dpeRequest = getAdemeFileJson(ademeId); /** @type {Contexte} */ diff --git a/src/features/engine/domain/enveloppe/ventilation/deperdition-ventilation.service.spec.js b/src/features/engine/domain/enveloppe/ventilation/deperdition-ventilation.service.spec.js index ad5d1e6..1374e0b 100755 --- a/src/features/engine/domain/enveloppe/ventilation/deperdition-ventilation.service.spec.js +++ b/src/features/engine/domain/enveloppe/ventilation/deperdition-ventilation.service.spec.js @@ -7,6 +7,7 @@ import { getAdemeFileJson } from '../../../../../../test/test-helpers.js'; import { DpeNormalizerService } from '../../../../normalizer/domain/dpe-normalizer.service.js'; import { ContexteBuilder } from '../../contexte.builder.js'; import { DeperditionEnveloppeService } from '../deperdition-enveloppe.service.js'; +import { describeIntegration } from '../../../../../../test/helpers/integration-test.js'; /** @type {DeperditionVentilationService} **/ let service; @@ -408,7 +409,7 @@ describe('Calcul de déperdition des portes', () => { ); }); - describe("Test d'intégration des ventilations", () => { + describeIntegration("Test d'intégration des ventilations", () => { test.each(corpus)('vérification des DI de la ventilation pour dpe %s', (ademeId) => { let dpeRequest = getAdemeFileJson(ademeId); dpeRequest = normalizerService.normalize(dpeRequest); diff --git a/src/features/normalizer/domain/dpe-normalizer.service.js b/src/features/normalizer/domain/dpe-normalizer.service.js index 257ed61..696b4e7 100755 --- a/src/features/normalizer/domain/dpe-normalizer.service.js +++ b/src/features/normalizer/domain/dpe-normalizer.service.js @@ -1,4 +1,4 @@ -import { DpePreProcessor } from '../../../../../preprocessor/index.js'; +import { DpePreProcessor } from '../../../preprocessor/index.js'; /** * Service de normalisation du DPE d'entrée diff --git a/src/preprocessor/index.js b/src/preprocessor/index.js new file mode 100644 index 0000000..a0380da --- /dev/null +++ b/src/preprocessor/index.js @@ -0,0 +1,20 @@ +/** + * Pré-traitement du DPE d'entrée. + * + * Placeholder minimal : la migration vers la nouvelle architecture `features/` prévoit un + * pré-processeur chargé de redresser / compléter les données d'entrée avant calcul. Tant que + * cette étape n'est pas implémentée, `preprocess` est un no-op qui laisse le DPE inchangé. + * + * Ce module existe pour que {@link DpeNormalizerService} soit chargeable (son import était cassé, + * pointant vers un fichier inexistant). Il n'est pas câblé dans le moteur legacy (`engine.js`). + */ +export class DpePreProcessor { + /** + * @param dpe {Dpe} DPE à pré-traiter (modifié sur place) + * @return {void} + */ + preprocess(dpe) { + // no-op : voir la description du module + return dpe; + } +} diff --git a/src/utils.js b/src/utils.js index d7439ca..e936929 100644 --- a/src/utils.js +++ b/src/utils.js @@ -2,6 +2,7 @@ import enums from './enums.js'; import tvs from './tv.js'; import { set } from 'lodash-es'; import { XMLParser } from 'fast-xml-parser'; +import { evaluate } from 'mathjs'; export const xmlParser = new XMLParser({ // We want to make sure collections of length 1 are still parsed as arrays @@ -473,6 +474,44 @@ export function containsAnySubstring(mainString, substrings) { ); } +/** + * Évalue une formule de type tableur issue des tables de valeurs des générateurs à combustion + * (ex. `Pn * (E + F * logPn) / 100`, `84 + 2 logPn`). + * + * Une valeur numérique est retournée telle quelle. Sinon la formule est évaluée avec les + * variables suivantes : + * - `Pn` : puissance nominale exprimée en kW (l'argument `pn` est fourni en W, donc divisé + * par 1000), + * - `logPn` : logarithme décimal de `Pn`, + * - `E`, `F`: coefficients forfaitaires (présence ou non d'une ventouse). + * + * @param formulaOrValue {string|number} Formule tableur ou valeur numérique. + * @param pn {number} Puissance nominale en W. + * @param E {number} [Coefficient forfaitaire E] + * @param F {number} [Coefficient forfaitaire F] + * @returns {number} Résultat de l'évaluation. + */ +export function excel_to_js_exec(formulaOrValue, pn, E, F) { + // Les formules des tables utilisent la virgule comme séparateur décimal (ex. `0,085`), + // à convertir en point pour être évaluées. + const formula = + typeof formulaOrValue === 'string' + ? formulaOrValue.replace(/(\d),(\d)/g, '$1.$2') + : formulaOrValue; + + if (!isNaN(formula)) { + return Number(formula); + } + + const Pn = pn / 1000; + return evaluate(formula, { + Pn, + logPn: Math.log10(Pn), + E, + F + }); +} + /** * Conversion des expressions du type `70 < Pn <= 400` en (70 < Pn) && (Pn <= 400) * @param expression {string} @@ -504,7 +543,7 @@ export function getRange(inputNumber, ranges) { ranges.sort(); - if (inputNumber <= ranges[0]) { + if (inputNumber < ranges[0]) { result.push(ranges[0]); result.push(ranges[1]); return result; diff --git a/src/utils.spec.js b/src/utils.spec.js index d66eaac..8b38743 100644 --- a/src/utils.spec.js +++ b/src/utils.spec.js @@ -1,4 +1,9 @@ -import { convertExpression, getRange, getThicknessFromDescription } from './utils.js'; +import { + convertExpression, + excel_to_js_exec, + getRange, + getThicknessFromDescription +} from './utils.js'; import { describe, expect, test } from 'vitest'; describe('Utils unit tests', () => { @@ -34,4 +39,39 @@ describe('Utils unit tests', () => { ])('should for values %s and inputNumber %s return range %s', (ranges, inputNumber, expected) => { expect(getRange(inputNumber, ranges)).toStrictEqual(expected); }); + + describe('excel_to_js_exec', () => { + // Règle : une valeur numérique est retournée telle quelle (le séparateur décimal virgule + // est converti en point au préalable). + test.each([ + ['12', 12], + ['0,6', 0.6], + [42, 42] + ])('retourne la valeur numérique %s telle quelle', (value, expected) => { + expect(excel_to_js_exec(value, 25000)).toBe(expected); + }); + + // Règle : `Pn` est exprimé en kW (l'argument `pn` fourni en W est divisé par 1000) et + // `logPn` vaut son logarithme décimal. + test('expose Pn en kW et logPn = log10(Pn)', () => { + // Pn = 25000 / 1000 = 25 → logPn = log10(25) + expect(excel_to_js_exec('Pn', 25000)).toBe(25); + expect(excel_to_js_exec('logPn', 25000)).toBeCloseTo(Math.log10(25), 12); + }); + + // Règle : le pourcentage est interprété comme une fraction (`0.6%` → 0.006). + test('interprète un pourcentage comme une fraction', () => { + expect(excel_to_js_exec('0.6%', 25000)).toBeCloseTo(0.006, 12); + }); + + // Valeurs de référence de régression (calculées via le vrai module). + test.each([ + // formule, pn, E, F, résultat attendu + ['84 + 2 logPn', 25000, undefined, undefined, 86.79588001734407], + ['Pn * (E + F * logPn) / 100', 50000, 2.5, -0.8, 0.5704119982655924], + ['0,085*Pn*(Pn)^-0,4', 25000, undefined, undefined, 0.5863851061210162] + ])('évalue la formule %s (pn=%s)', (formula, pn, E, F, expected) => { + expect(excel_to_js_exec(formula, pn, E, F)).toBeCloseTo(expected, 9); + }); + }); }); diff --git a/test/helpers/integration-test.js b/test/helpers/integration-test.js new file mode 100644 index 0000000..27ecae0 --- /dev/null +++ b/test/helpers/integration-test.js @@ -0,0 +1,19 @@ +import { describe, test } from 'vitest'; + +/** + * Certains tests ne sont pas des tests unitaires isolés : ils s'appuient sur le corpus DPE réel + * (fixtures `test/fixtures/*.json`, `test/corpus-sano.json`, CSV de corpus) ou fixent des valeurs + * de régression couplées au moteur de calcul. Ils sont donc exclus du run unitaire par défaut + * (et de la CI) pour ne dépendre que de tests déterministes et isolés. + * + * Pour les exécuter explicitement : + * RUN_INTEGRATION_TESTS=1 npx vitest run + * ou via le script `npm run test:integration`. + */ +const runIntegration = !!process.env.RUN_INTEGRATION_TESTS; + +/** `describe` actif uniquement en mode intégration, sinon `describe.skip`. */ +export const describeIntegration = runIntegration ? describe : describe.skip; + +/** `test` actif uniquement en mode intégration, sinon `test.skip`. */ +export const testIntegration = runIntegration ? test : test.skip;