@open3cl/engine 1.7.1 → 1.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,7 +4,7 @@ import { updateGenerateurBouilleurs } from './13.2_generateur_combustion_bouille
4
4
  import { updateGenerateurChaudieres } from './13.2_generateur_combustion_chaudiere.js';
5
5
  import { updateGenerateurPacs } from './13.2_generateur_pac.js';
6
6
  import getFicheTechnique from './ficheTechnique.js';
7
- import { evaluate } from 'mathjs';
7
+ import { evaluateFormula as evaluateTvFormula } from './formula.js';
8
8
 
9
9
  function criterePn(Pn, matcher) {
10
10
  let critere_list = tvColumnLines('generateur_combustion', 'critere_pn', matcher);
@@ -38,15 +38,7 @@ const F_tab = {
38
38
  };
39
39
 
40
40
  function evaluateFormula(formulaOrValue, pn, E, F) {
41
- const pnVariable = pn / 1000;
42
- if (!isNaN(formulaOrValue)) {
43
- return Number(formulaOrValue);
44
- }
45
- const variables = new Map();
46
- variables.set('Pn', pnVariable);
47
- variables.set('E', E);
48
- variables.set('F', F);
49
- return evaluate(formulaOrValue, variables);
41
+ return evaluateTvFormula(formulaOrValue, pn, E, F);
50
42
  }
51
43
 
52
44
  /**
package/3.1_b.js CHANGED
@@ -1,8 +1,47 @@
1
1
  import enums from './enums.js';
2
2
  import { tv, requestInput, requestInputID, bug_for_bug_compat } from './utils.js';
3
3
 
4
+ const RANGES = [0.25, 0.5, 0.75, 1, 1.25, 2, 2.5, 3, 3.5, 4, 6, 8, 10, 25, 50];
5
+
6
+ /**
7
+ * Identifiants de type d'adjacence pour lesquels le ratio aiu/aue est pris en compte.
8
+ */
9
+ const ADJACENCE_AIU_AUE_IDS = new Set([
10
+ '8',
11
+ '9',
12
+ '11',
13
+ '12',
14
+ '13',
15
+ '14',
16
+ '15',
17
+ '16',
18
+ '17',
19
+ '18',
20
+ '19',
21
+ '21'
22
+ ]);
23
+
24
+ const TYPE_ADJACENCE_IDS = Object.keys(enums.type_adjacence);
25
+
26
+ /**
27
+ * Formate une borne du ratio aiu/aue au format des tables de valeurs (`0,25`, `1,00`…).
28
+ *
29
+ * `toLocaleString(undefined, { minimumFractionDigits: 2, … })` construisait un
30
+ * `Intl.NumberFormat` à chaque appel (~29 µs), ce qui représentait l'essentiel du
31
+ * temps CPU de `b()`. `toFixed(2)` produit exactement les mêmes chaînes pour toutes
32
+ * les valeurs de `RANGES`, en ~0,2 µs, et sans dépendre de la locale de la machine —
33
+ * le `.replace('.', ',')` qui suivait était d'ailleurs un no-op sur une machine en
34
+ * locale française et une vraie conversion ailleurs.
35
+ *
36
+ * @param borne {number}
37
+ * @returns {string}
38
+ */
39
+ function formatBorneAiuAue(borne) {
40
+ return borne.toFixed(2).replace('.', ',');
41
+ }
42
+
4
43
  export function findRanges(inputNumber) {
5
- const ranges = [0.25, 0.5, 0.75, 1, 1.25, 2, 2.5, 3, 3.5, 4, 6, 8, 10, 25, 50];
44
+ const ranges = RANGES;
6
45
  const result = [];
7
46
 
8
47
  if (inputNumber < ranges[0]) {
@@ -28,7 +67,7 @@ export function findRanges(inputNumber) {
28
67
  export default function b(di, de, du, zc_id) {
29
68
  const zc = enums.zone_climatique[zc_id].slice(0, 2);
30
69
 
31
- du.enum_type_adjacence_id = Object.keys(enums.type_adjacence);
70
+ du.enum_type_adjacence_id = TYPE_ADJACENCE_IDS;
32
71
  const matcher = {
33
72
  enum_type_adjacence_id: requestInputID(de, du, 'type_adjacence')
34
73
  };
@@ -37,11 +76,7 @@ export default function b(di, de, du, zc_id) {
37
76
  matcher.zone_climatique = zc;
38
77
  matcher.enum_cfg_isolation_lnc_id = requestInputID(de, du, 'cfg_isolation_lnc');
39
78
  /* du.enum_cfg_isolation_lnc_id = ['6', '7', '8', '9', '10', '11'] */
40
- } else if (
41
- ['8', '9', '11', '12', '13', '14', '15', '16', '17', '18', '19', '21'].includes(
42
- de.enum_type_adjacence_id.toString()
43
- )
44
- ) {
79
+ } else if (ADJACENCE_AIU_AUE_IDS.has(de.enum_type_adjacence_id.toString())) {
45
80
  if (de.surface_aue === 0) {
46
81
  // cf page 10
47
82
  // NOTE: bizarre de regarder aue pour un local chauffé non accessible
@@ -96,36 +131,13 @@ export default function b(di, de, du, zc_id) {
96
131
  // Si le rapport des surfaces est égale à la borne inférieure, utilisation de la borne supérieure
97
132
  // et prise en compte de l'égalité
98
133
  if (ranges[0] === de.surface_aiu / de.surface_aue) {
99
- matcher.aiu_aue_max =
100
- '≤ ' +
101
- ranges[0]
102
- .toLocaleString(undefined, {
103
- minimumFractionDigits: 2,
104
- maximumFractionDigits: 2,
105
- useGrouping: false
106
- })
107
- .replace('.', ',');
134
+ matcher.aiu_aue_max = '≤ ' + formatBorneAiuAue(ranges[0]);
108
135
  } else {
109
- matcher.aiu_aue_min =
110
- ranges[0]
111
- .toLocaleString(undefined, {
112
- minimumFractionDigits: 2,
113
- maximumFractionDigits: 2,
114
- useGrouping: false
115
- })
116
- .replace('.', ',') + ' <';
136
+ matcher.aiu_aue_min = formatBorneAiuAue(ranges[0]) + ' <';
117
137
  }
118
138
  }
119
139
  if (ranges[1]) {
120
- matcher.aiu_aue_max =
121
- '≤ ' +
122
- ranges[1]
123
- .toLocaleString(undefined, {
124
- minimumFractionDigits: 2,
125
- maximumFractionDigits: 2,
126
- useGrouping: false
127
- })
128
- .replace('.', ',');
140
+ matcher.aiu_aue_max = '≤ ' + formatBorneAiuAue(ranges[1]);
129
141
  }
130
142
  }
131
143
  }
@@ -23,6 +23,37 @@ function tv_ug(di, de, du) {
23
23
  }
24
24
  }
25
25
 
26
+ /**
27
+ * Valeurs de Ug tabulées dans `uw`, indexées par (type de baie, matériau de menuiserie).
28
+ *
29
+ * Le scan des 723 lignes de `tvs.uw` — avec un `split('|')` par ligne — était refait
30
+ * pour chaque baie vitrée de chaque DPE, alors que la table est statique.
31
+ *
32
+ * Le tableau est partagé et `getRange()` le trie sur place : le tri par défaut de
33
+ * `Array#sort` étant déterministe, la première mise en cache le laisse trié et les
34
+ * appels suivants produisent exactement le même résultat qu'avec un tableau reconstruit.
35
+ */
36
+ const uwUgValuesCache = new Map();
37
+
38
+ function uwUgValues(enum_type_baie_id, enum_type_materiaux_menuiserie_id) {
39
+ const cacheKey = `${enum_type_baie_id}${enum_type_materiaux_menuiserie_id}`;
40
+ let values = uwUgValuesCache.get(cacheKey);
41
+ if (values === undefined) {
42
+ values = tvs.uw
43
+ .filter((row) => {
44
+ return (
45
+ row.enum_type_baie_id.split('|').includes(enum_type_baie_id) &&
46
+ row.enum_type_materiaux_menuiserie_id
47
+ .split('|')
48
+ .includes(enum_type_materiaux_menuiserie_id)
49
+ );
50
+ })
51
+ .map((row) => parseFloat(row.ug));
52
+ uwUgValuesCache.set(cacheKey, values);
53
+ }
54
+ return values;
55
+ }
56
+
26
57
  function tv_uw(di, de) {
27
58
  const enum_type_baie_id = de.enum_type_baie_id;
28
59
  const matcher = { enum_type_baie_id };
@@ -46,16 +77,7 @@ function tv_uw(di, de) {
46
77
  matcher.enum_type_materiaux_menuiserie_id = enum_type_materiaux_menuiserie_id;
47
78
 
48
79
  // Récupération de toutes les valeurs de Ug présentes dans les tables Uw pour le type de baie et matériaux de la baie vitrée
49
- const ugValues = tvs.uw
50
- .filter((row) => {
51
- return (
52
- row.enum_type_baie_id.split('|').includes(enum_type_baie_id) &&
53
- row.enum_type_materiaux_menuiserie_id
54
- .split('|')
55
- .includes(enum_type_materiaux_menuiserie_id)
56
- );
57
- })
58
- .map((row) => parseFloat(row.ug));
80
+ const ugValues = uwUgValues(enum_type_baie_id, enum_type_materiaux_menuiserie_id);
59
81
 
60
82
  /**
61
83
  * 3.3.2 Coefficients Uw des fenêtres / portes-fenêtres
@@ -1,6 +1,6 @@
1
1
  import enums from './enums.js';
2
2
  import { isNil } from 'lodash-es';
3
- import { tv, requestInput, compareReferences, bug_for_bug_compat } from './utils.js';
3
+ import { tv, requestInput, cleanReference, bug_for_bug_compat } from './utils.js';
4
4
 
5
5
  function defaultValue(type_liaison, pt_di, de) {
6
6
  if (pt_di.k === 0) {
@@ -115,10 +115,20 @@ function tv_k(pt_di, di, de, du, pc_id, logement) {
115
115
  }
116
116
  }
117
117
 
118
+ /**
119
+ * Références du pont thermique nettoyées une seule fois : elles sont confrontées à toutes les
120
+ * parois de plusieurs listes ci-dessous, et `compareReferences` les renettoyait à chaque
121
+ * comparaison. Les affectations de `de.reference_1` / `de.reference_2` ont toutes eu lieu
122
+ * au-dessus, dans le bloc de récupération par description.
123
+ */
124
+ const cleaned_reference_1 = cleanReference(de.reference_1);
125
+ const cleaned_reference_2 = cleanReference(de.reference_2);
126
+
118
127
  let mur = mur_list.find((mur, murIndex) => {
128
+ const cleaned_mur_reference = cleanReference(mur.donnee_entree.reference);
119
129
  if (
120
- compareReferences(mur.donnee_entree.reference, de.reference_1) ||
121
- compareReferences(mur.donnee_entree.reference, de.reference_2)
130
+ cleaned_mur_reference === cleaned_reference_1 ||
131
+ cleaned_mur_reference === cleaned_reference_2
122
132
  ) {
123
133
  return true;
124
134
  }
@@ -190,11 +200,10 @@ function tv_k(pt_di, di, de, du, pc_id, logement) {
190
200
  case 'plancher bas / mur':
191
201
  case 'plancher haut lourd / mur': {
192
202
  const plancher_list = ph_list.concat(pb_list);
193
- const plancher = plancher_list.find(
194
- (plancher) =>
195
- compareReferences(plancher.donnee_entree.reference, de.reference_1) ||
196
- compareReferences(plancher.donnee_entree.reference, de.reference_2)
197
- );
203
+ const plancher = plancher_list.find((plancher) => {
204
+ const cleaned = cleanReference(plancher.donnee_entree.reference);
205
+ return cleaned === cleaned_reference_1 || cleaned === cleaned_reference_2;
206
+ });
198
207
  if (!plancher) {
199
208
  di.k = defaultValue(type_liaison, pt_di, de);
200
209
  console.error(
@@ -331,11 +340,10 @@ function tv_k(pt_di, di, de, du, pc_id, logement) {
331
340
  }
332
341
 
333
342
  const menuiserie_list = bv_list.concat(porte_list);
334
- const menuiserie = menuiserie_list.find(
335
- (men) =>
336
- compareReferences(men.donnee_entree.reference, de.reference_1) ||
337
- compareReferences(men.donnee_entree.reference, de.reference_2)
338
- );
343
+ const menuiserie = menuiserie_list.find((men) => {
344
+ const cleaned = cleanReference(men.donnee_entree.reference);
345
+ return cleaned === cleaned_reference_1 || cleaned === cleaned_reference_2;
346
+ });
339
347
  if (!menuiserie) {
340
348
  di.k = defaultValue(type_liaison, pt_di, de);
341
349
  console.error(
@@ -30,7 +30,7 @@ vi.mock('./utils.js', () => ({
30
30
  set_bug_for_bug_compat: vi.fn(),
31
31
  tv: vi.fn(),
32
32
  requestInput: (de, du, field) => de[field],
33
- compareReferences: (a, b) => a === b,
33
+ cleanReference: (reference) => reference,
34
34
  get bug_for_bug_compat() {
35
35
  return state.bug;
36
36
  }
package/README.md CHANGED
@@ -385,7 +385,7 @@ Ces résultats sont **générés automatiquement** à la fin de `npm run test:co
385
385
 
386
386
  <!-- CORPUS:START -->
387
387
 
388
- > **Version `1.6.2`** · branche `main` · généré le 2026-09-11
388
+ > **Version `1.6.2`** · branche `main` · généré le 2026-09-18
389
389
  > Seuil de tolérance **5%**
390
390
 
391
391
  <table>
@@ -411,6 +411,36 @@ Ces résultats sont **générés automatiquement** à la fin de `npm run test:co
411
411
 
412
412
  <sub>🟢 ≥ 85 % · 🟡 ≥ 60 % · 🔴 < 60 %</sub>
413
413
 
414
+ #### Temps d’exécution
415
+
416
+ > Durée de l’appel à `calcul_3cl` par DPE, sur 89 956 calculs.
417
+ > La copie défensive de l’entrée et la lecture du fichier sont exclues de la mesure.
418
+
419
+ <table>
420
+ <tr>
421
+ <td align="center"><strong>5,61 ms</strong><br/><sub>moyenne</sub></td>
422
+ <td align="center"><strong>5,03 ms</strong><br/><sub>médiane</sub></td>
423
+ <td align="center"><strong>0,11 ms</strong><br/><sub>min</sub></td>
424
+ <td align="center"><strong>705,8 ms</strong><br/><sub>max</sub></td>
425
+ </tr>
426
+ </table>
427
+
428
+ | Corpus | Moyenne | Médiane | Min | Max | p95 | p99 |
429
+ | :--------------------------------------------- | ------: | ------: | ------: | -------: | ------: | ------: |
430
+ | **Généraliste** | 4,50 ms | 4,09 ms | 0,20 ms | 34,8 ms | 8,12 ms | 11,3 ms |
431
+ | **Appartement · chauffage individuel (2025)** | 3,71 ms | 3,46 ms | 1,32 ms | 26,1 ms | 6,22 ms | 7,99 ms |
432
+ | **Logement individuel (2025)** | 5,30 ms | 4,84 ms | 1,52 ms | 42,5 ms | 9,73 ms | 13,2 ms |
433
+ | **Maison individuelle (2025)** | 6,54 ms | 6,02 ms | 1,71 ms | 54,5 ms | 11,6 ms | 16,4 ms |
434
+ | **Immeuble · chauffage individuel** | 7,17 ms | 6,22 ms | 0,11 ms | 137,7 ms | 14,3 ms | 22,3 ms |
435
+ | **Appartement · chauffage collectif (2025)** | 3,98 ms | 3,76 ms | 1,22 ms | 37,6 ms | 6,35 ms | 8,00 ms |
436
+ | **Immeuble · chauffage collectif** | 6,40 ms | 5,58 ms | 0,15 ms | 101,0 ms | 12,1 ms | 21,2 ms |
437
+ | **Immeuble · chauffage mixte** | 6,01 ms | 5,01 ms | 0,22 ms | 705,8 ms | 12,4 ms | 19,8 ms |
438
+ | **Individuel généré depuis l'immeuble (2026)** | 6,89 ms | 6,27 ms | 1,52 ms | 65,1 ms | 13,0 ms | 19,7 ms |
439
+
440
+ <sub>La moyenne est tirée vers le haut par les DPE collectifs, dont le coût atteint plusieurs
441
+ dizaines de fois la médiane : c’est la médiane qui décrit le cas courant, et p95/p99 le cas
442
+ défavorable réel.</sub>
443
+
414
444
  <!-- CORPUS:END -->
415
445
 
416
446
  📈 Historique complet des versions : [docs/CORPUS-HISTORY.md](docs/CORPUS-HISTORY.md)
package/engine.js CHANGED
@@ -33,7 +33,7 @@ import getFicheTechnique from './ficheTechnique.js';
33
33
  import { ProductionENR } from './16.2_production_enr.js';
34
34
  import DpeSanitizerService from './dpe-sanitizer.service.js';
35
35
 
36
- const LIB_VERSION = '1.7.1';
36
+ const LIB_VERSION = '1.7.2';
37
37
 
38
38
  const dpeSanitizerService = new DpeSanitizerService();
39
39
 
package/ficheTechnique.js CHANGED
@@ -1,3 +1,85 @@
1
+ /**
2
+ * Recherche dans les fiches techniques d'un DPE.
3
+ *
4
+ * L'implémentation précédente rebalayait la totalité des fiches à chaque appel, concaténait les
5
+ * sous-fiches dans un `reduce` (`acc.concat(...)` recopie l'accumulateur à chaque itération) et
6
+ * remettait en minuscules des valeurs qui ne changent jamais. Le coût par appel croissait en
7
+ * `fiches^1,3`.
8
+ *
9
+ * Sur un DPE de logement individuel, une poignée de fiches : invisible. Sur un DPE collectif,
10
+ * les fiches suivent les installations — un DPE mesuré comptait 235 fiches et 1597 sous-fiches
11
+ * pour 73 générateurs — et la fonction représentait alors **54 % du temps de `calcul_3cl`**,
12
+ * le ramasse-miettes des concaténations comptant pour 10 % de plus.
13
+ *
14
+ * Les fiches techniques ne sont jamais modifiées pendant un calcul : elles sont donc indexées
15
+ * une fois par DPE, par catégorie, avec les minuscules pré-calculées. L'index est mémorisé dans
16
+ * une `WeakMap` sur le DPE lui-même, donc libéré avec lui — rien à vider, aucune fuite possible
17
+ * sur un process qui enchaîne les calculs.
18
+ */
19
+
20
+ /** @type {WeakMap<object, Map<string, {sousFiches: any[], valeursMinuscules: (string|null)[], champsMinuscules: Map<string, (string|null)[]>}[]>>} */
21
+ const indexParDpe = new WeakMap();
22
+
23
+ /**
24
+ * Regroupe les sous-fiches par catégorie, en conservant l'ordre du document — l'ordre détermine
25
+ * quelle correspondance est retournée en premier.
26
+ *
27
+ * @param dpe {FullDpe}
28
+ */
29
+ function indexerFichesTechniques(dpe) {
30
+ const parCategorie = new Map();
31
+
32
+ /** @type {FicheTechniqueItem[]} */
33
+ let fichesTechniques = dpe.fiche_technique_collection?.fiche_technique || [];
34
+ if (!Array.isArray(fichesTechniques)) {
35
+ fichesTechniques = [fichesTechniques];
36
+ }
37
+
38
+ for (const ficheTechnique of fichesTechniques) {
39
+ if (!ficheTechnique) continue;
40
+
41
+ const categorie = ficheTechnique.enum_categorie_fiche_technique_id;
42
+ let groupes = parCategorie.get(categorie);
43
+ if (!groupes) {
44
+ groupes = [];
45
+ parCategorie.set(categorie, groupes);
46
+ }
47
+
48
+ const brut = ficheTechnique.sous_fiche_technique_collection?.sous_fiche_technique;
49
+ const sousFiches = brut === undefined ? [] : Array.isArray(brut) ? brut : [brut];
50
+
51
+ groupes.push({
52
+ sousFiches,
53
+ // `valeur` en minuscules, calculée une fois ; null quand ce n'est pas une chaîne, auquel
54
+ // cas la comparaison se fait par identité stricte comme avant.
55
+ valeursMinuscules: sousFiches.map((sousFiche) =>
56
+ typeof sousFiche?.valeur === 'string' ? sousFiche.valeur.toLowerCase() : null
57
+ ),
58
+ // Les autres champs sont mis en minuscules à la demande : seuls `description` et `valeur`
59
+ // sont interrogés en pratique, inutile de préparer le reste.
60
+ champsMinuscules: new Map()
61
+ });
62
+ }
63
+
64
+ return parCategorie;
65
+ }
66
+
67
+ /**
68
+ * @param groupe {{sousFiches: any[], champsMinuscules: Map<string, (string|null)[]>}}
69
+ * @param field {string}
70
+ * @returns {(string|null)[]}
71
+ */
72
+ function champEnMinuscules(groupe, field) {
73
+ let valeurs = groupe.champsMinuscules.get(field);
74
+ if (valeurs === undefined) {
75
+ valeurs = groupe.sousFiches.map((sousFiche) =>
76
+ sousFiche && sousFiche[field] ? sousFiche[field].toString().toLowerCase() : null
77
+ );
78
+ groupe.champsMinuscules.set(field, valeurs);
79
+ }
80
+ return valeurs;
81
+ }
82
+
1
83
  /**
2
84
  * Retourne si elle existe la fiche technique contenant le texte description et
3
85
  * présente dans la catégorie de fiches techniques ayant pour catégorie categoryFicheTechiqueId
@@ -15,72 +97,70 @@ export default function getFicheTechnique(
15
97
  classifications = [],
16
98
  field = 'description'
17
99
  ) {
18
- /** @type {FicheTechniqueItem[]} */
19
- let fichesTechniques = dpe.fiche_technique_collection?.fiche_technique || [];
100
+ let index = indexParDpe.get(dpe);
101
+ if (index === undefined) {
102
+ index = indexerFichesTechniques(dpe);
103
+ indexParDpe.set(dpe, index);
104
+ }
20
105
 
21
- if (!Array.isArray(fichesTechniques)) {
22
- fichesTechniques = [fichesTechniques];
106
+ const groupes = index.get(categoryFicheTechiqueId);
107
+ if (!groupes) {
108
+ return null;
23
109
  }
24
110
 
25
111
  classifications = classifications.filter((classification) => classification);
112
+ const aiguilles = classifications.map((classification) =>
113
+ classification.toString().toLowerCase()
114
+ );
115
+ const descriptionMinuscule = description.toLowerCase();
26
116
 
27
- fichesTechniques = fichesTechniques
28
- .filter((ficheTechnique) => ficheTechnique)
29
- .reduce((acc, ficheTechnique) => {
30
- if (ficheTechnique.enum_categorie_fiche_technique_id === categoryFicheTechiqueId) {
31
- /**
32
- * Plusieurs collections de fiches techniques peuvent exister pour la même catégorie (pour 2 systèmes ECS par exemple)
33
- * Le champs classification permet de trouver la collection qui convient en filtrant sur une seconde donnée
34
- */
35
- if (classifications.length) {
36
- /** @type {SousFicheTechniqueItem[]} */
37
- let sousFichesTechniques =
38
- ficheTechnique.sous_fiche_technique_collection.sous_fiche_technique;
39
-
40
- if (!Array.isArray(sousFichesTechniques)) {
41
- sousFichesTechniques = [sousFichesTechniques];
42
- }
43
-
44
- const allContained = classifications.every((classification) =>
45
- sousFichesTechniques.some((ficheTechnique) => {
46
- const valeur = ficheTechnique?.valeur;
47
-
48
- if (typeof valeur === 'string') {
49
- return (
50
- ficheTechnique.valeur
51
- .toLowerCase()
52
- .indexOf(classification.toString().toLowerCase()) !== -1
53
- );
54
- }
117
+ for (const groupe of groupes) {
118
+ /**
119
+ * Plusieurs collections de fiches techniques peuvent exister pour la même catégorie (pour 2
120
+ * systèmes ECS par exemple). Le champ classification permet de trouver la collection qui
121
+ * convient en filtrant sur une seconde donnée.
122
+ */
123
+ if (classifications.length) {
124
+ let toutesPresentes = true;
55
125
 
56
- return ficheTechnique.valeur === classification;
57
- })
58
- );
126
+ for (let i = 0; i < classifications.length; i++) {
127
+ const aiguille = aiguilles[i];
128
+ let presente = false;
59
129
 
60
- if (!allContained) {
61
- return acc;
130
+ for (let j = 0; j < groupe.sousFiches.length; j++) {
131
+ const valeurMinuscule = groupe.valeursMinuscules[j];
132
+ if (valeurMinuscule !== null) {
133
+ if (valeurMinuscule.indexOf(aiguille) !== -1) {
134
+ presente = true;
135
+ break;
136
+ }
137
+ } else if (groupe.sousFiches[j].valeur === classifications[i]) {
138
+ presente = true;
139
+ break;
62
140
  }
63
141
  }
64
142
 
65
- return acc.concat(ficheTechnique.sous_fiche_technique_collection.sous_fiche_technique);
143
+ if (!presente) {
144
+ toutesPresentes = false;
145
+ break;
146
+ }
66
147
  }
67
- return acc;
68
- }, []);
69
-
70
- if (!fichesTechniques.length) {
71
- return null;
72
- }
73
148
 
74
- const firstFiche = fichesTechniques.filter(
75
- (ficheTechnique) =>
76
- ficheTechnique &&
77
- ficheTechnique[field] &&
78
- ficheTechnique[field].toString().toLowerCase().indexOf(description.toLowerCase()) !== -1
79
- );
149
+ if (!toutesPresentes) continue;
150
+ }
80
151
 
81
- if (!firstFiche.length) {
82
- return null;
152
+ // L'ancienne implémentation aplatissait toutes les catégories correspondantes avant de
153
+ // filtrer : la première correspondance du tableau aplati est la première correspondance du
154
+ // premier groupe qui en contient une. Parcourir les groupes dans l'ordre donne le même
155
+ // résultat, sans construire le tableau intermédiaire.
156
+ const champs = champEnMinuscules(groupe, field);
157
+ for (let j = 0; j < groupe.sousFiches.length; j++) {
158
+ const champ = champs[j];
159
+ if (champ !== null && champ.indexOf(descriptionMinuscule) !== -1) {
160
+ return groupe.sousFiches[j];
161
+ }
162
+ }
83
163
  }
84
164
 
85
- return firstFiche[0];
165
+ return null;
86
166
  }
@@ -188,21 +188,53 @@ describe('FicheTechnique service tests', () => {
188
188
 
189
189
  fiche = getFicheTechnique(dpe, '10', 'non existing');
190
190
  expect(fiche).toBeNull();
191
+ });
191
192
 
192
- dpe.fiche_technique_collection.fiche_technique[0].sous_fiche_technique_collection.sous_fiche_technique =
193
- null;
194
-
195
- fiche = getFicheTechnique(dpe, '11', 'non existing');
196
- expect(fiche).toBeNull();
197
-
198
- dpe.fiche_technique_collection.fiche_technique[0].sous_fiche_technique_collection = null;
199
-
200
- fiche = getFicheTechnique(dpe, '11', 'non existing');
201
- expect(fiche).toBeNull();
202
-
203
- dpe.fiche_technique_collection.fiche_technique = null;
193
+ /**
194
+ * Les fiches techniques sont indexées une fois par objet DPE : chaque cas utilise donc un DPE
195
+ * neuf, muter un DPE déjà interrogé n'aurait aucun effet.
196
+ */
197
+ describe('fiches techniques absentes ou incomplètes', () => {
198
+ const buildDpe = (ficheTechnique) => ({
199
+ fiche_technique_collection: { fiche_technique: ficheTechnique }
200
+ });
201
+
202
+ test.each([
203
+ ['sans fiche_technique_collection', {}],
204
+ ['avec fiche_technique null', buildDpe(null)],
205
+ ['avec une fiche technique null', buildDpe([null])],
206
+ [
207
+ 'sans sous_fiche_technique_collection',
208
+ buildDpe([
209
+ { enum_categorie_fiche_technique_id: '10', sous_fiche_technique_collection: null }
210
+ ])
211
+ ],
212
+ [
213
+ 'avec des sous-fiches null ou sans description',
214
+ buildDpe([
215
+ {
216
+ enum_categorie_fiche_technique_id: '10',
217
+ sous_fiche_technique_collection: {
218
+ sous_fiche_technique: [null, { valeur: 'plusieurs' }]
219
+ }
220
+ }
221
+ ])
222
+ ]
223
+ ])('retourne null %s', (_, dpeIncomplet) => {
224
+ expect(getFicheTechnique(dpeIncomplet, '10', 'exposées')).toBeNull();
225
+ });
226
+
227
+ test('ignore les fiches techniques null au milieu des autres', () => {
228
+ const fiche = { description: 'Façades exposées: plusieurs', valeur: 'plusieurs' };
229
+ const dpeAvecTrou = buildDpe([
230
+ null,
231
+ {
232
+ enum_categorie_fiche_technique_id: '10',
233
+ sous_fiche_technique_collection: { sous_fiche_technique: [fiche] }
234
+ }
235
+ ]);
204
236
 
205
- fiche = getFicheTechnique(dpe, '10', 'non existing');
206
- expect(fiche).toBeNull();
237
+ expect(getFicheTechnique(dpeAvecTrou, '10', 'exposées')).toBe(fiche);
238
+ });
207
239
  });
208
240
  });