@open3cl/engine 1.7.1 → 1.7.3

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.
@@ -129,20 +129,29 @@ export class DeperditionMurService extends DeperditionService {
129
129
  }
130
130
 
131
131
  /**
132
- * Pour l’ensemble des parois, la présence d’un doublage apporte une résistance thermique supplémentaire
132
+ * Pour l’ensemble des parois, la présence d’un doublage apporte une résistance thermique supplémentaire.
133
+ * Cependant, lorsqu'une isolation ITE ou ITI est présente, le doublage NE doit PAS être cumulé.
134
+ * Le doublage n'est pris en compte que si aucune isolation ITE/ITI n'est appliquée.
135
+ * Types d'isolation avec ITE ou ITI : 3 (iti), 4 (ite), 6 (iti+ite), 7 (iti+itr), 8 (ite+itr)
136
+ * @see https://github.com/Open3CL/engine/issues/146
133
137
  */
134
- switch (murDE.enum_type_doublage_id) {
135
- case '3':
136
- // doublage indéterminé ou lame d'air inf 15 mm
137
- umur0 = 1 / (1 / umur0 + 0.1);
138
- break;
139
- case '4': // doublage indéterminé ou lame d'air sup 15 mm
140
- case '5': // doublage connu (plâtre brique bois)
141
- umur0 = 1 / (1 / umur0 + 0.21);
142
- break;
143
- default:
144
- // absence de doublage ou inconnu
145
- break;
138
+ const typeIsolation = parseInt(murDE.enum_type_isolation_id) || 1;
139
+ const hasIteOrIti = [3, 4, 6, 7, 8].includes(typeIsolation);
140
+
141
+ if (!hasIteOrIti) {
142
+ switch (murDE.enum_type_doublage_id) {
143
+ case '3':
144
+ // doublage indéterminé ou lame d'air inf 15 mm
145
+ umur0 = 1 / (1 / umur0 + 0.1);
146
+ break;
147
+ case '4': // doublage indéterminé ou lame d'air sup 15 mm
148
+ case '5': // doublage connu (plâtre brique bois)
149
+ umur0 = 1 / (1 / umur0 + 0.21);
150
+ break;
151
+ default:
152
+ // absence de doublage ou inconnu
153
+ break;
154
+ }
146
155
  }
147
156
 
148
157
  /**
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
  });
package/formula.js ADDED
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Évaluation des formules tableur présentes dans les tables de valeurs.
3
+ *
4
+ * Remplace `mathjs` — dont seul `evaluate()` était utilisé, pour une grammaire très réduite —
5
+ * par un analyseur dédié. mathjs tirait 113 modules (mathjs, decimal.js, fraction.js,
6
+ * typed-function, seedrandom, @babel/runtime) et ~500 ms d'initialisation, et re-parsait la
7
+ * chaîne de la formule à chaque appel.
8
+ *
9
+ * Grammaire supportée, alignée sur ce que mathjs acceptait pour ces formules :
10
+ * - nombres décimaux, séparateur `.` ou `,` (`0,085` ≡ `0.085`)
11
+ * - pourcentages : `0.6%` vaut 0.006
12
+ * - variables : `Pn`, `logPn`, `E`, `F`
13
+ * - fonction : `log10(x)`
14
+ * - opérateurs : `+` `-` `*` `/` `^` et parenthèses
15
+ * - multiplication implicite : `2 logPn` ≡ `2 * logPn`
16
+ *
17
+ * `^` est associatif à droite et lie plus fort que le moins unaire (`-2^2` vaut -4,
18
+ * `2^-1` vaut 0.5), comme dans mathjs.
19
+ *
20
+ * Chaque formule n'est analysée qu'une fois : le résultat est une fermeture mise en cache.
21
+ */
22
+
23
+ const VARIABLES = ['Pn', 'logPn', 'E', 'F'];
24
+ const FUNCTIONS = { log10: Math.log10 };
25
+
26
+ /* --------------------------------------------------------------------------------------- */
27
+ /* Analyse lexicale */
28
+ /* --------------------------------------------------------------------------------------- */
29
+
30
+ /**
31
+ * @param source {string}
32
+ * @returns {{type: string, value: *}[]}
33
+ */
34
+ function tokenize(source) {
35
+ const tokens = [];
36
+ let i = 0;
37
+
38
+ while (i < source.length) {
39
+ const c = source[i];
40
+
41
+ if (c === ' ' || c === '\t' || c === '\n' || c === '\r') {
42
+ i += 1;
43
+ continue;
44
+ }
45
+
46
+ if (c >= '0' && c <= '9') {
47
+ let j = i;
48
+ while (j < source.length && source[j] >= '0' && source[j] <= '9') j += 1;
49
+ if (source[j] === '.' || source[j] === ',') {
50
+ j += 1;
51
+ while (j < source.length && source[j] >= '0' && source[j] <= '9') j += 1;
52
+ }
53
+ let value = Number(source.slice(i, j).replace(',', '.'));
54
+ i = j;
55
+ // Un `%` suffixant un nombre en fait une fraction : `0.6%` → 0.006.
56
+ while (i < source.length && (source[i] === ' ' || source[i] === '\t')) i += 1;
57
+ if (source[i] === '%') {
58
+ value /= 100;
59
+ i += 1;
60
+ }
61
+ tokens.push({ type: 'number', value });
62
+ continue;
63
+ }
64
+
65
+ if (/[A-Za-z_]/.test(c)) {
66
+ let j = i;
67
+ while (j < source.length && /[A-Za-z0-9_]/.test(source[j])) j += 1;
68
+ tokens.push({ type: 'name', value: source.slice(i, j) });
69
+ i = j;
70
+ continue;
71
+ }
72
+
73
+ if ('+-*/^()'.includes(c)) {
74
+ tokens.push({ type: c, value: c });
75
+ i += 1;
76
+ continue;
77
+ }
78
+
79
+ throw new SyntaxError(`caractère inattendu \`${c}\` dans la formule \`${source}\``);
80
+ }
81
+
82
+ return tokens;
83
+ }
84
+
85
+ /* --------------------------------------------------------------------------------------- */
86
+ /* Analyse syntaxique — descente récursive, produit une fermeture (scope) => number */
87
+ /* --------------------------------------------------------------------------------------- */
88
+
89
+ /**
90
+ * @param source {string}
91
+ * @returns {(scope: {Pn: number, logPn: number, E: number, F: number}) => number}
92
+ */
93
+ function parse(source) {
94
+ const tokens = tokenize(source);
95
+ let pos = 0;
96
+
97
+ const peek = () => tokens[pos];
98
+ const eat = (type) => {
99
+ if (!tokens[pos] || tokens[pos].type !== type) {
100
+ throw new SyntaxError(`\`${type}\` attendu dans la formule \`${source}\``);
101
+ }
102
+ return tokens[pos++];
103
+ };
104
+
105
+ /** Un jeton qui peut démarrer un facteur — sert à détecter la multiplication implicite. */
106
+ const startsFactor = () => {
107
+ const t = peek();
108
+ return !!t && (t.type === 'number' || t.type === 'name' || t.type === '(');
109
+ };
110
+
111
+ function parseExpression() {
112
+ let left = parseTerm();
113
+ while (peek() && (peek().type === '+' || peek().type === '-')) {
114
+ const op = tokens[pos++].type;
115
+ const right = parseTerm();
116
+ const l = left;
117
+ left = op === '+' ? (s) => l(s) + right(s) : (s) => l(s) - right(s);
118
+ }
119
+ return left;
120
+ }
121
+
122
+ function parseTerm() {
123
+ let left = parseUnary();
124
+ for (;;) {
125
+ const t = peek();
126
+ if (t && (t.type === '*' || t.type === '/')) {
127
+ const op = tokens[pos++].type;
128
+ const right = parseUnary();
129
+ const l = left;
130
+ left = op === '*' ? (s) => l(s) * right(s) : (s) => l(s) / right(s);
131
+ } else if (startsFactor()) {
132
+ // multiplication implicite : `2 logPn`
133
+ const right = parseUnary();
134
+ const l = left;
135
+ left = (s) => l(s) * right(s);
136
+ } else {
137
+ return left;
138
+ }
139
+ }
140
+ }
141
+
142
+ function parseUnary() {
143
+ const t = peek();
144
+ if (t && (t.type === '+' || t.type === '-')) {
145
+ pos += 1;
146
+ const operand = parseUnary();
147
+ return t.type === '-' ? (s) => -operand(s) : operand;
148
+ }
149
+ return parsePower();
150
+ }
151
+
152
+ function parsePower() {
153
+ const base = parsePrimary();
154
+ if (peek() && peek().type === '^') {
155
+ pos += 1;
156
+ // associatif à droite, et l'exposant peut être signé : `(Pn)^-0.4`
157
+ const exponent = parseUnary();
158
+ return (s) => base(s) ** exponent(s);
159
+ }
160
+ return base;
161
+ }
162
+
163
+ function parsePrimary() {
164
+ const t = peek();
165
+ if (!t) throw new SyntaxError(`formule incomplète : \`${source}\``);
166
+
167
+ if (t.type === 'number') {
168
+ pos += 1;
169
+ const { value } = t;
170
+ return () => value;
171
+ }
172
+
173
+ if (t.type === '(') {
174
+ pos += 1;
175
+ const inner = parseExpression();
176
+ eat(')');
177
+ return inner;
178
+ }
179
+
180
+ if (t.type === 'name') {
181
+ pos += 1;
182
+ const name = t.value;
183
+
184
+ if (peek() && peek().type === '(') {
185
+ const fn = FUNCTIONS[name];
186
+ if (!fn) throw new SyntaxError(`fonction inconnue \`${name}\` dans \`${source}\``);
187
+ pos += 1;
188
+ const arg = parseExpression();
189
+ eat(')');
190
+ return (s) => fn(arg(s));
191
+ }
192
+
193
+ if (!VARIABLES.includes(name)) {
194
+ throw new SyntaxError(`variable inconnue \`${name}\` dans \`${source}\``);
195
+ }
196
+ return (s) => s[name];
197
+ }
198
+
199
+ throw new SyntaxError(`jeton inattendu \`${t.value}\` dans \`${source}\``);
200
+ }
201
+
202
+ const compiled = parseExpression();
203
+ if (pos !== tokens.length) {
204
+ throw new SyntaxError(`fin de formule inattendue dans \`${source}\``);
205
+ }
206
+ return compiled;
207
+ }
208
+
209
+ /* --------------------------------------------------------------------------------------- */
210
+ /* API */
211
+ /* --------------------------------------------------------------------------------------- */
212
+
213
+ const compiledFormulas = new Map();
214
+
215
+ /**
216
+ * Compile une formule (ou la récupère depuis le cache).
217
+ *
218
+ * @param formula {string}
219
+ * @returns {(scope: {Pn: number, logPn: number, E: number, F: number}) => number}
220
+ */
221
+ export function compileFormula(formula) {
222
+ let compiled = compiledFormulas.get(formula);
223
+ if (compiled === undefined) {
224
+ compiled = parse(formula);
225
+ compiledFormulas.set(formula, compiled);
226
+ }
227
+ return compiled;
228
+ }
229
+
230
+ /**
231
+ * Évalue une formule tableur, ou retourne la valeur telle quelle si c'en est déjà une.
232
+ *
233
+ * @param formulaOrValue {string|number} Formule tableur ou valeur numérique.
234
+ * @param pn {number} Puissance nominale en W (exposée à la formule sous `Pn`, en kW).
235
+ * @param E {number} [Coefficient forfaitaire E]
236
+ * @param F {number} [Coefficient forfaitaire F]
237
+ * @returns {number}
238
+ */
239
+ export function evaluateFormula(formulaOrValue, pn, E, F) {
240
+ const formula =
241
+ typeof formulaOrValue === 'string'
242
+ ? formulaOrValue.replace(/(\d),(\d)/g, '$1.$2')
243
+ : formulaOrValue;
244
+
245
+ if (!isNaN(formula)) {
246
+ return Number(formula);
247
+ }
248
+
249
+ const Pn = pn / 1000;
250
+ return compileFormula(formula)({ Pn, logPn: Math.log10(Pn), E, F });
251
+ }
@@ -0,0 +1,99 @@
1
+ import { describe, expect, test } from 'vitest';
2
+ import { compileFormula, evaluateFormula } from './formula.js';
3
+
4
+ describe('formula', () => {
5
+ describe('evaluateFormula', () => {
6
+ test.each([
7
+ [42, 42],
8
+ ['42', 42],
9
+ ['0.5', 0.5],
10
+ ['12,5', 12.5]
11
+ ])('retourne la valeur numérique telle quelle : %s → %s', (input, expected) => {
12
+ expect(evaluateFormula(input, 24000)).toBe(expected);
13
+ });
14
+
15
+ test('expose Pn en kW à partir de pn en W', () => {
16
+ expect(evaluateFormula('Pn', 24000)).toBe(24);
17
+ });
18
+
19
+ test('expose logPn = log10(Pn)', () => {
20
+ expect(evaluateFormula('logPn', 100000)).toBeCloseTo(2, 10);
21
+ });
22
+
23
+ test('expose les coefficients E et F', () => {
24
+ expect(evaluateFormula('E + F * logPn', 100000, 3, 1.5)).toBeCloseTo(6, 10);
25
+ });
26
+
27
+ test('accepte la virgule comme séparateur décimal', () => {
28
+ expect(evaluateFormula('0,085 * Pn', 10000)).toBeCloseTo(0.85, 10);
29
+ expect(evaluateFormula('84,5 + 2 logPn', 100000)).toBeCloseTo(88.5, 10);
30
+ });
31
+
32
+ test('évalue des formules représentatives des tables de valeurs', () => {
33
+ // rendement à pleine charge d'une chaudière : 84 + 2 logPn
34
+ expect(evaluateFormula('84 + 2 logPn', 100000)).toBeCloseTo(88, 10);
35
+ // pertes à l'arrêt : exposant négatif signé
36
+ expect(evaluateFormula('2.5 * (Pn)^-0.4', 32000)).toBeCloseTo(2.5 * 32 ** -0.4, 10);
37
+ // pourcentage
38
+ expect(evaluateFormula('0.6% * Pn', 50000)).toBeCloseTo(0.3, 10);
39
+ });
40
+ });
41
+
42
+ describe('compileFormula', () => {
43
+ const evaluate = (formula, scope = {}) => compileFormula(formula)(scope);
44
+
45
+ test.each([
46
+ ['2 + 3 * 4', 14],
47
+ ['(2 + 3) * 4', 20],
48
+ ['10 / 4', 2.5],
49
+ ['8 - 3 - 2', 3],
50
+ ['16 / 4 / 2', 2],
51
+ ['2^3^2', 512],
52
+ ['-2^2', -4],
53
+ ['2^-1', 0.5],
54
+ ['-(3 - 5)', 2],
55
+ ['+3', 3],
56
+ ['--3', 3],
57
+ ['log10(1000)', 3],
58
+ ['log10(10 * 10)', 2],
59
+ ['2(3 + 1)', 8],
60
+ ['2 log10(100)', 4],
61
+ ['0.6%', 0.006],
62
+ ['50 %', 0.5],
63
+ ['0,5 + 1', 1.5],
64
+ [' 1 +\t2\n', 3]
65
+ ])('%s → %s', (formula, expected) => {
66
+ expect(evaluate(formula)).toBeCloseTo(expected, 10);
67
+ });
68
+
69
+ test('lit les variables dans le scope', () => {
70
+ const scope = { Pn: 20, logPn: Math.log10(20), E: 2, F: 3 };
71
+ expect(evaluate('Pn', scope)).toBe(20);
72
+ expect(evaluate('E F', scope)).toBe(6);
73
+ expect(evaluate('E + F Pn', scope)).toBe(62);
74
+ expect(evaluate('logPn', scope)).toBeCloseTo(Math.log10(20), 10);
75
+ });
76
+
77
+ test('met en cache la formule compilée', () => {
78
+ const first = compileFormula('Pn * 2 + 1');
79
+ expect(compileFormula('Pn * 2 + 1')).toBe(first);
80
+ expect(first({ Pn: 3 })).toBe(7);
81
+ expect(first({ Pn: 5 })).toBe(11);
82
+ });
83
+
84
+ test.each([
85
+ ['2 $ 3', /caractère inattendu `\$`/],
86
+ ['foo + 1', /variable inconnue `foo`/],
87
+ ['sqrt(4)', /fonction inconnue `sqrt`/],
88
+ ['(2 + 3', /`\)` attendu/],
89
+ ['log10(2', /`\)` attendu/],
90
+ ['2 +', /formule incomplète/],
91
+ ['', /formule incomplète/],
92
+ ['2 )', /fin de formule inattendue/],
93
+ ['* 2', /jeton inattendu `\*`/]
94
+ ])('rejette la formule invalide `%s`', (formula, message) => {
95
+ expect(() => compileFormula(formula)).toThrow(SyntaxError);
96
+ expect(() => compileFormula(formula)).toThrow(message);
97
+ });
98
+ });
99
+ });