diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index cbbf3eb9..5443e75e 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:unit: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/.gitignore b/.gitignore index d358cdb2..6fd47d7b 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,4 @@ dist/reports/corpus/*/*.csv !dist/reports/corpus/**/corpus_dpe_list_above_threshold_main.json *.db +/test-results/junit.xml diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..3d9c788c --- /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. diff --git a/package-lock.json b/package-lock.json index ea18f3dc..42f3df15 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/package.json b/package.json index 7b48e0b4..f6ba5034 100755 --- a/package.json +++ b/package.json @@ -14,7 +14,10 @@ "scripts": { "http-server": "http-server", "test": "vitest run", - "test:ci": "vitest run --coverage", + "test:unit": "vitest run src", + "test:unit:ci": "vitest run src --coverage", + "test:int": "vitest run test", + "test:int:ci": "vitest run test --coverage", "test:corpus": "node test/corpus/run_all_corpus_checks.js", "test:corpus:all": "node test/corpus/run_all_corpus_checks.js", "test:corpus:all+database": "npm run test:corpus:all && node test/corpus/corpus_database.js", diff --git a/src/10_besoin_fr.spec.js b/src/10_besoin_fr.spec.js new file mode 100644 index 00000000..dfb3e490 --- /dev/null +++ b/src/10_besoin_fr.spec.js @@ -0,0 +1,122 @@ +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('taux Rbth exactement égal à 1 : facteur d’utilisation futj = a / (a + 1)', () => { + // Rbth = (aij + asj) / (GV * (textmoy - Tint) * nrefj) = 2000 / (100 * 2 * 10) = 1 + // valeur de référence de régression + expect(calc_besoin_fr_j(100, 100, 'moyenne', 1000, 1000, 10, 30, 28)).toBeCloseTo( + 3.604395604395604, + 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); + }); +}); diff --git a/src/10_clim.spec.js b/src/10_clim.spec.js new file mode 100644 index 00000000..bbcea434 --- /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/11_besoin_ecs.spec.js b/src/11_besoin_ecs.spec.js new file mode 100644 index 00000000..73e1aad0 --- /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 00000000..a5164cc2 --- /dev/null +++ b/src/11_ecs.spec.js @@ -0,0 +1,282 @@ +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('virtualisation sans clé de répartition : ratio = 1 (valeur de repli)', () => { + const de = { tv_rendement_distribution_ecs_id: '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(1); + expect(ecs.donnee_intermediaire.besoin_ecs).toBe(100); + }); + + test('immeuble à systèmes ECS individuels sans nombre d’appartements : ratio calculé au prorata', () => { + const de = { + tv_rendement_distribution_ecs_id: '5', + surface_habitable: 50, + rdim: 1, + nombre_logement: 10 + }; + const ecs = makeEcs(de); + calc_ecs({}, ecs, 100, 200, 1, 'ca1', 'h1a', 'immeuble', false, 200, null, true); + + // ratio = ((surface_habitable / surfaceImmeuble) * rdim) / (nombre_logement * nombreAppartements) + // nombreAppartements est nul -> division par 0 => ratio infini, on vérifie seulement la branche prise + expect(ecs.donnee_intermediaire.ratio_besoin_ecs).toBe(Infinity); + }); + + test('immeuble à systèmes ECS individuels : surface immeuble et rdim absents utilisent le repli 1', () => { + const de = { + tv_rendement_distribution_ecs_id: '5', + surface_habitable: 50, + nombre_logement: 10 + }; + const ecs = makeEcs(de); + // surfaceImmeuble null et de.rdim absent -> les deux `|| 1` s'appliquent + calc_ecs({}, ecs, 100, 200, 1, 'ca1', 'h1a', 'immeuble', false, null, null, true); + + expect(ecs.donnee_intermediaire.ratio_besoin_ecs).toBe(Infinity); + }); + + test('rdim infini : la valeur de repli 1 s’applique (1 / Infinity vaut 0)', () => { + const de = { tv_rendement_distribution_ecs_id: '5', rdim: Infinity }; + const ecs = makeEcs(de); + calc_ecs({}, ecs, 100, 200, 1, 'ca1', 'h1a', 'maison', false, null, null, false); + + // 1 / Infinity = 0 -> `|| 1` prend le repli + expect(ecs.donnee_intermediaire.ratio_besoin_ecs).toBe(1); + }); + + 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' + }); + }); + + test('installation collective multi-bâtiment isolée bouclée : configuration non contiguë', () => { + vi.mocked(requestInput).mockImplementation((de, du, field) => { + if (field === 'reseau_distribution_isole') return 1; + if (field === 'bouclage_reseau_ecs') return "réseau d'ecs bouclé"; + return undefined; + }); + const de = { enum_type_installation_id: '3' }; + 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 isolé bouclé', + configuration_logement: 'majorité des logements avec pièces alimentées non contiguës' + }); + }); + + test('installation collective isolée sans bouclage (traçage) : configuration ignorée', () => { + vi.mocked(requestInput).mockImplementation((de, du, field) => { + if (field === 'reseau_distribution_isole') return 1; + if (field === 'bouclage_reseau_ecs') return "réseau d'ecs non bouclé"; + return 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); + + // configuration_logement mise à null -> absente du matcher, seul le type de réseau subsiste + expect(tv).toHaveBeenCalledWith('rendement_distribution_ecs', { + type_reseau_collectif: + 'Réseau collectif isolé avec traçage ou Réseau collectif isolé sans traçage ni bouclage' + }); + }); + + test('aucune ligne forfaitaire trouvée : rendement de distribution non renseigné', () => { + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.mocked(tv).mockReturnValue(undefined); + 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).toBeUndefined(); + expect(errSpy).toHaveBeenCalled(); + errSpy.mockRestore(); + }); +}); + +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/11_nadeq.spec.js b/src/11_nadeq.spec.js index f75db1c5..50fa25d6 100644 --- a/src/11_nadeq.spec.js +++ b/src/11_nadeq.spec.js @@ -53,4 +53,41 @@ describe('Nadeq unit tests', () => { expect(nadeq.calculateNadeq(logement)).toBeCloseTo(expectedNadeq, 2); } ); + + /** + * Pour un appartement individuel, la méthode collective est appelée avec la surface habitable du + * logement et un unique appartement (Nappart = 1). + * enum_methode_application_dpe_log_id ∈ {2,3,4,5,31,32,35,36,37} + */ + test.each([ + // Shmoy < 10 => Nmax = 1 + [2, 1, 8], + [3, 1, 8], + // 10 <= Shmoy < 50 => Nmax = 1.75 - 0.01875 * (50 - Shmoy) ; Shmoy = 45 => 1.65625 + [4, 1.65625, 45], + // Shmoy >= 50 => Nmax = 0.035 * Shmoy = 2.625 (> 1.75) => 1.75 + 0.3 * (2.625 - 1.75) = 2.0125 + [37, 2.0125, 75] + ])( + 'should get appartement nadeq (méthode %s) = %s pour surface_habitable_logement %s', + (enum_methode_application_dpe_log_id, expectedNadeq, surface_habitable_logement) => { + const logement = { + caracteristique_generale: { + enum_methode_application_dpe_log_id, + surface_habitable_logement + } + }; + expect(nadeq.calculateNadeq(logement)).toBeCloseTo(expectedNadeq, 4); + } + ); + + test('les identifiants de méthode sont comparés numériquement (chaîne "1" acceptée)', () => { + const logement = { + caracteristique_generale: { + enum_methode_application_dpe_log_id: '1', + surface_habitable_logement: 8 + } + }; + // méthode individuelle : surface < 30 => Nmax = 1 + expect(nadeq.calculateNadeq(logement)).toBe(1); + }); }); diff --git a/src/12.4_pac.spec.js b/src/12.4_pac.spec.js new file mode 100644 index 00000000..6d05917c --- /dev/null +++ b/src/12.4_pac.spec.js @@ -0,0 +1,131 @@ +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('tv_scop_id du DPE différent de celui de la table : avertissement mais valeur de la table utilisée', () => { + tv.mockReturnValue({ scop_ou_cop: 'scop', scop: '4.2', tv_scop_id: '15' }); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + // tv_scop_id renseigné (18) et différent de celui de la ligne (15) + const di = {}; + const de = { enum_type_generateur_ch_id: '160', tv_scop_id: 18 }; + + scopOrCop(di, de, {}, 'h1a', null, 'ch'); + + expect(errorSpy).toHaveBeenCalledTimes(1); + // la valeur de la table prime : tv_scop_id est corrigé + expect(de.tv_scop_id).toBe(15); + expect(di.rg).toBe(4.2); + errorSpy.mockRestore(); + }); + + test('tv_scop_id du DPE identique à celui de la table : pas d’avertissement', () => { + tv.mockReturnValue({ scop_ou_cop: 'scop', scop: '4.2', tv_scop_id: '15' }); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const di = {}; + const de = { enum_type_generateur_ch_id: '160', tv_scop_id: 15 }; + + scopOrCop(di, de, {}, 'h1a', null, 'ch'); + + expect(errorSpy).not.toHaveBeenCalled(); + expect(de.tv_scop_id).toBe(15); + errorSpy.mockRestore(); + }); + + 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.js b/src/13.2_generateur_combustion.js index e05a42ea..50e30dcd 100644 --- a/src/13.2_generateur_combustion.js +++ b/src/13.2_generateur_combustion.js @@ -190,6 +190,8 @@ export function tv_generateur_combustion(dpe, di, de, type, GV, tbase, methodeSa if (methodeSaisie === 1 || !di.pveilleuse) { di.pveil = Number(row.pveil) || 0; } else { + // La branche else n'est atteinte que si di.pveilleuse est vrai, donc le repli `|| 0` est mort. + /* c8 ignore next */ di.pveil = di.pveilleuse || 0; } } diff --git a/src/13.2_generateur_combustion.spec.js b/src/13.2_generateur_combustion.spec.js new file mode 100644 index 00000000..0de68821 --- /dev/null +++ b/src/13.2_generateur_combustion.spec.js @@ -0,0 +1,537 @@ +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. + */ +const state = vi.hoisted(() => ({ bug: false })); + +vi.mock('./utils.js', () => ({ + get bug_for_bug_compat() { + return state.bug; + }, + 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', + X: 'chaudière fioul', + Y: 'chaudière gaz' + }, + type_generateur_ecs: { + 50: 'chaudière gaz standard', + A: 'chaudière gaz', + B: 'chaudière fioul', + 84: "système collectif par défaut en abscence d'information" + } + } +})); + +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(); + state.bug = false; +}); + +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(); + }); + + test('ratio de virtualisation absent : valeur de repli 1', () => { + tv.mockReturnValue({ ...ROW_DEFAUT, qp0_perc: 'Pn' }); + const di = { pn: 20000 }; + // pas de ratio_virtualisation -> `|| 1` + const de = { enum_type_generateur_ch_id: '89', presence_ventouse: 0 }; + tv_generateur_combustion({}, di, de, 'ch', 200, -9, 1); + // qp0_perc = 'Pn' => qp0 = Pn(kW) * 1000 * ratio(=1) = pn + expect(di.qp0).toBe(20000); + }); + + test('qp0_perc exprimé en pourcentage : qp0 proportionnel à la puissance nominale', () => { + tv.mockReturnValue({ ...ROW_DEFAUT, qp0_perc: '2%' }); + 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); + // '2%' => 0.02 (mathjs) ; contient '%' mais pas 'Pn' => qp0 = 0.02 * pn + expect(di.qp0).toBeCloseTo(400, 9); + }); + + test('qp0_perc absent : qp0 nul', () => { + const row = { ...ROW_DEFAUT }; + delete row.qp0_perc; + tv.mockReturnValue(row); + 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.qp0).toBe(0); + }); + + test('pveil forfaitaire absent : valeur de repli 0', () => { + const row = { ...ROW_DEFAUT }; + delete row.pveil; + tv.mockReturnValue(row); + 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.pveil).toBe(0); + }); + + test('critère de puissance : le critère satisfait est renseigné dans le matcher', () => { + tvColumnLines.mockReturnValue(['Pn ≤ 50', 'Pn > 50']); + 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); + // Pn = 20000 / (1 * 1000) = 20 ≤ 50 -> premier critère retenu, '≤' restauré + expect(tv).toHaveBeenCalledWith( + 'generateur_combustion', + expect.objectContaining({ critere_pn: 'Pn ≤ 50' }) + ); + }); + + test('critère de puissance : aucun critère satisfait -> avertissement et critère nul', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + tvColumnLines.mockReturnValue(['Pn > 100']); + 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); + // Pn = 20 n'est pas > 100 -> aucun critère satisfait (ret reste undefined) + expect(tv).toHaveBeenCalledWith( + 'generateur_combustion', + expect.objectContaining({ critere_pn: undefined }) + ); + expect(warnSpy).toHaveBeenCalled(); + warnSpy.mockRestore(); + }); +}); + +/** + * 13.2 - Comportements spécifiques activés par bug_for_bug_compat (redressement de données DPE). + */ +describe('tv_generateur_combustion - compatibilité bug_for_bug_compat', () => { + test('générateur ECS collectif par défaut (84) : caractéristiques issues de tv_generateur_combustion_id', () => { + state.bug = true; + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + tv.mockReturnValue({ ...ROW_DEFAUT }); + const di = { pn: 20000 }; + const de = { + enum_type_generateur_ecs_id: '84', + tv_generateur_combustion_id: '42', + ratio_virtualisation: 1, + presence_ventouse: 0 + }; + tv_generateur_combustion({}, di, de, 'ecs', 200, -9, 1); + // La ligne est récupérée directement via l'identifiant du générateur à combustion + expect(tv).toHaveBeenCalledWith('generateur_combustion', { + tv_generateur_combustion_id: '42' + }); + expect(de.tv_generateur_combustion_id).toBe(42); + expect(warnSpy).toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + test('générateur CH collectif par défaut (119) : même récupération directe', () => { + state.bug = true; + vi.spyOn(console, 'warn').mockImplementation(() => {}); + tv.mockReturnValue({ ...ROW_DEFAUT }); + const di = { pn: 20000 }; + const de = { + enum_type_generateur_ch_id: '119', + tv_generateur_combustion_id: '42', + ratio_virtualisation: 1, + presence_ventouse: 0 + }; + tv_generateur_combustion({}, di, de, 'ch', 200, -9, 1); + expect(tv).toHaveBeenCalledWith('generateur_combustion', { + tv_generateur_combustion_id: '42' + }); + vi.restoreAllMocks(); + }); + + test('type 84 sans tv_generateur_combustion_id : retour au chemin forfaitaire nominal', () => { + state.bug = true; + tv.mockReturnValue({ ...ROW_DEFAUT }); + const di = { pn: 20000 }; + const de = { enum_type_generateur_ecs_id: '84', ratio_virtualisation: 1, presence_ventouse: 0 }; + tv_generateur_combustion({}, di, de, 'ecs', 200, -9, 1); + // Pas d'identifiant -> matcher forfaitaire construit avec le type de générateur + expect(tv).toHaveBeenCalledWith( + 'generateur_combustion', + expect.objectContaining({ enum_type_generateur_ecs_id: '84' }) + ); + }); + + test('ECS : identifiant utilisé différent de celui du DPE non compatible -> erreur signalée', () => { + state.bug = true; + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + tv.mockImplementation((table, matcher) => { + // La ligne du DPE (id 99) ne référence pas le type de générateur ECS courant + if (matcher.tv_generateur_combustion_id === '99') { + return { tv_generateur_combustion_id: '99', enum_type_generateur_ecs_id: '60|61' }; + } + return { ...ROW_DEFAUT }; + }); + const di = { pn: 20000 }; + const de = { + enum_type_generateur_ecs_id: '50', + tv_generateur_combustion_id: '99', + ratio_virtualisation: 1, + presence_ventouse: 0 + }; + tv_generateur_combustion({}, di, de, 'ecs', 200, -9, 1); + expect(errSpy).toHaveBeenCalled(); + errSpy.mockRestore(); + }); + + test('ECS : identifiant du DPE cohérent avec le type de générateur -> aucune erreur', () => { + state.bug = true; + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + tv.mockImplementation((table, matcher) => { + if (matcher.tv_generateur_combustion_id === '99') { + return { tv_generateur_combustion_id: '99', enum_type_generateur_ecs_id: '50|60' }; + } + return { ...ROW_DEFAUT }; + }); + const di = { pn: 20000 }; + const de = { + enum_type_generateur_ecs_id: '50', + tv_generateur_combustion_id: '99', + ratio_virtualisation: 1, + presence_ventouse: 0 + }; + tv_generateur_combustion({}, di, de, 'ecs', 200, -9, 1); + // '50' figure dans '50|60' -> pas d'incohérence signalée + expect(errSpy).not.toHaveBeenCalled(); + errSpy.mockRestore(); + }); + + test('ECS : ligne DPE introuvable pour l’identifiant saisi -> aucune erreur', () => { + state.bug = true; + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + tv.mockImplementation((table, matcher) => { + if (matcher.tv_generateur_combustion_id === '99') return undefined; + return { ...ROW_DEFAUT }; + }); + const di = { pn: 20000 }; + const de = { + enum_type_generateur_ecs_id: '50', + tv_generateur_combustion_id: '99', + ratio_virtualisation: 1, + presence_ventouse: 0 + }; + tv_generateur_combustion({}, di, de, 'ecs', 200, -9, 1); + expect(errSpy).not.toHaveBeenCalled(); + errSpy.mockRestore(); + }); +}); + +/** + * 13.2 - Génération mixte Chauffage + ECS : redressement du type de générateur ECS. + */ +describe('tv_generateur_combustion - génération mixte (checkEcsVsChauffageForMixteGeneration)', () => { + /** Fabrique un dpe contenant un générateur de chauffage mixte (usage 3). */ + function dpeAvecChMixte(chId) { + return { + logement: { + installation_chauffage_collection: { + installation_chauffage: [ + { + generateur_chauffage_collection: { + generateur_chauffage: [ + { + donnee_entree: { + enum_usage_generateur_id: '3', + enum_type_generateur_ch_id: chId + } + } + ] + } + } + ] + } + } + }; + } + + test('types ECS et CH différents : le type de générateur ECS est aligné sur celui du chauffage', () => { + state.bug = true; + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + tv.mockReturnValue({ ...ROW_DEFAUT }); + const di = { pn: 20000 }; + // ECS 'A' = chaudière gaz ; CH mixte 'X' = chaudière fioul -> alignement sur 'B' (chaudière fioul ECS) + const de = { + enum_type_generateur_ecs_id: 'A', + enum_usage_generateur_id: '3', + tv_generateur_combustion_id: '42', + ratio_virtualisation: 1, + presence_ventouse: 0 + }; + tv_generateur_combustion(dpeAvecChMixte('X'), di, de, 'ecs', 200, -9, 1); + expect(tv).toHaveBeenCalledWith( + 'generateur_combustion', + expect.objectContaining({ enum_type_generateur_ecs_id: 'B' }) + ); + expect(errSpy).toHaveBeenCalled(); + errSpy.mockRestore(); + }); + + test('types ECS et CH identiques : type de générateur ECS conservé', () => { + state.bug = true; + tv.mockReturnValue({ ...ROW_DEFAUT }); + const di = { pn: 20000 }; + // ECS 'A' = chaudière gaz ; CH mixte 'Y' = chaudière gaz -> pas de changement + const de = { + enum_type_generateur_ecs_id: 'A', + enum_usage_generateur_id: '3', + tv_generateur_combustion_id: '42', + ratio_virtualisation: 1, + presence_ventouse: 0 + }; + tv_generateur_combustion(dpeAvecChMixte('Y'), di, de, 'ecs', 200, -9, 1); + expect(tv).toHaveBeenCalledWith( + 'generateur_combustion', + expect.objectContaining({ enum_type_generateur_ecs_id: 'A' }) + ); + }); + + test('aucun générateur de chauffage mixte : type de générateur ECS conservé', () => { + state.bug = true; + tv.mockReturnValue({ ...ROW_DEFAUT }); + const di = { pn: 20000 }; + const dpe = { + logement: { + installation_chauffage_collection: { + installation_chauffage: [ + { + generateur_chauffage_collection: { + // usage 1 (non mixte) -> filtré + generateur_chauffage: [ + { + donnee_entree: { + enum_usage_generateur_id: '1', + enum_type_generateur_ch_id: 'X' + } + } + ] + } + } + ] + } + } + }; + const de = { + enum_type_generateur_ecs_id: 'A', + enum_usage_generateur_id: '3', + tv_generateur_combustion_id: '42', + ratio_virtualisation: 1, + presence_ventouse: 0 + }; + tv_generateur_combustion(dpe, di, de, 'ecs', 200, -9, 1); + expect(tv).toHaveBeenCalledWith( + 'generateur_combustion', + expect.objectContaining({ enum_type_generateur_ecs_id: 'A' }) + ); + }); + + test('générateur ECS par défaut (previous dans la liste d’exclusion) : aucun redressement', () => { + state.bug = true; + tv.mockReturnValue({ ...ROW_DEFAUT }); + const di = { pn: 20000 }; + const de = { + enum_type_generateur_ecs_id: 'A', + previous_enum_type_generateur_ecs_id: '78', + enum_usage_generateur_id: '3', + tv_generateur_combustion_id: '42', + ratio_virtualisation: 1, + presence_ventouse: 0 + }; + tv_generateur_combustion(dpeAvecChMixte('X'), di, de, 'ecs', 200, -9, 1); + // previous = '78' (autre système à combustion gaz) -> pas assez précis, on conserve 'A' + expect(tv).toHaveBeenCalledWith( + 'generateur_combustion', + expect.objectContaining({ enum_type_generateur_ecs_id: 'A' }) + ); + }); +}); + +/** + * 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.js b/src/13.2_generateur_combustion_bouilleur.js index 8b7c881a..e3cdb1f4 100644 --- a/src/13.2_generateur_combustion_bouilleur.js +++ b/src/13.2_generateur_combustion_bouilleur.js @@ -142,7 +142,10 @@ function updateGenerateurBouilleur(dpe, ids, de, type) { * 2012. * - Les poêles à bois bouilleur installées avant 2012 sont traités comme des chaudières bois installées entre 1978 et 1994. */ - let newGenerateurId = generateurId === 13 ? values[1978] : values[2004]; + let newGenerateurId = values[2004]; + // branche morte : generateurId provient de Object.keys (chaîne), jamais === 13 (nombre) + /* c8 ignore next */ + if (generateurId === 13) newGenerateurId = values[1978]; if (ficheTechnique) { if (ficheTechnique.toString().toLowerCase() === 'avant 1948') { 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 00000000..6f747b6c --- /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 00000000..8e83813e --- /dev/null +++ b/src/13.2_generateur_combustion_ch.spec.js @@ -0,0 +1,323 @@ +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. + */ +const state = vi.hoisted(() => ({ bug: false })); + +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', + 70: 'chaudière gaz basse température 2001-2015', + 12: 'radiateur à gaz', + 33: 'générateur à air chaud', + 99: 'type de générateur inconnu' + }, + type_energie: { + 2: 'gaz naturel', + 4: 'bois – bûches' + } + } +})); + +vi.mock('./utils.js', () => ({ + get bug_for_bug_compat() { + return state.bug; + }, + 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(); + state.bug = false; +}); + +/** + * 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); + }); + + test('conserve la température à 100 % la plus élevée entre plusieurs émetteurs', () => { + tv.mockImplementation((table, matcher) => { + if (table !== 'temp_fonc_100') return null; + return matcher.enum_temp_distribution_ch_id === '3' + ? { tv_temp_fonc_100_id: 'a', temp_fonc_100: '60' } + : { tv_temp_fonc_100_id: 'b', temp_fonc_100: '75' }; + }); + const di = {}; + tv_temp_fonc_30_100( + di, + { enum_type_generateur_ch_id: '89' }, + {}, + [emetteur(), emetteur({ enum_temp_distribution_ch_id: '4' })], + 1975 + ); + // La comparaison `>` conserve la plus grande des deux valeurs + expect(di.temp_fonc_100).toBe(75); + }); + + test('aucune ligne forfaitaire trouvée : erreurs signalées pour temp_fonc_30 et temp_fonc_100', () => { + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + tv.mockReturnValue(null); + const di = {}; + tv_temp_fonc_30_100(di, { enum_type_generateur_ch_id: '89' }, {}, [emetteur()], 1975); + expect(di.temp_fonc_30).toBeUndefined(); + expect(di.temp_fonc_100).toBeUndefined(); + expect(errSpy).toHaveBeenCalledTimes(2); + errSpy.mockRestore(); + }); +}); + +/** + * 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('radiateur à gaz : branche de calcul dédiée', () => { + const di = { pn: 20000, rpn: 0.9, rpint: 0.85, qp0: 200, temp_fonc_30: 40, temp_fonc_100: 70 }; + const de = { + enum_type_generateur_ch_id: '12', + 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 + expect(di.rg).toBeCloseTo(0.8851321252668564, 9); + expect(di.rg_dep).toBeCloseTo(0.8869221230591896, 9); + }); + + test('générateur à air chaud : branche de calcul dédiée', () => { + const di = { pn: 20000, rpn: 0.9, rpint: 0.85, qp0: 200, temp_fonc_30: 40, temp_fonc_100: 70 }; + const de = { + enum_type_generateur_ch_id: '33', + 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 + expect(di.rg).toBeCloseTo(0.8655833354015402, 9); + expect(di.rg_dep).toBeCloseTo(0.8746954076850985, 9); + }); + + test('chaudière basse température : coefficients dédiés (a = 0.1, b = 40)', () => { + const di = { pn: 20000, rpn: 0.9, rpint: 0.85, qp0: 200, temp_fonc_30: 40, temp_fonc_100: 70 }; + const de = { + enum_type_generateur_ch_id: '70', + 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 + expect(di.rg).toBeCloseTo(0.8513271171577527, 9); + expect(di.rg_dep).toBeCloseTo(0.8655664711109103, 9); + }); + + test('type de générateur inconnu : aucun rendement calculé (avertissement)', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const di = { pn: 20000, rpn: 0.9, rpint: 0.85, qp0: 200, temp_fonc_30: 40, temp_fonc_100: 70 }; + const de = { + enum_type_generateur_ch_id: '99', + enum_type_energie_id: '2', + type_energie: 'gaz naturel', + description: 't' + }; + calc_generateur_combustion_ch({}, di, de, { cdimref: 0.8, cdimrefDep: 0.6 }); + // QPx renvoie undefined pour un type non reconnu -> rendement NaN + expect(Number.isNaN(di.rg)).toBe(true); + expect(warnSpy).toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + test('cdimref absent (du vide) : valeur de repli 0 -> Tch_xfinal saturé à 1', () => { + const di = { pn: 20000, 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 -> x/0 = Infinity -> min(1, ...) = 1 pour tous les x, nominal = dépensier + expect(di.rg).toBeCloseTo(0.8967281540325838, 9); + expect(di.rg_dep).toBeCloseTo(0.8967281540325838, 9); + }); + + test('bug_for_bug_compat : correction de qp0 exprimé en kW (< 1) vers des W', () => { + state.bug = true; + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const di = { pn: 20000, rpn: 0.9, rpint: 0.85, qp0: 0.2, 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 }); + // qp0 < 1 -> multiplié par 1000 => 200 (passage kW -> W) + expect(di.qp0).toBe(200); + // valeur de référence de régression (chaudière standard, pn = 20000, qp0 corrigé à 200) + expect(di.rg).toBeCloseTo(0.8566147558351076, 9); + warnSpy.mockRestore(); + }); + + 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 00000000..abaf024d --- /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 00000000..d68cd2e4 --- /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/13_rendement_distribution_ecs.spec.js b/src/13_rendement_distribution_ecs.spec.js new file mode 100644 index 00000000..ae5e8e0f --- /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(); + }); +}); diff --git a/src/14_generateur_ecs.js b/src/14_generateur_ecs.js index 08b75da9..a009ff4a 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 00000000..02602dc2 --- /dev/null +++ b/src/14_generateur_ecs.spec.js @@ -0,0 +1,745 @@ +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. + */ +const state = vi.hoisted(() => ({ bug: false })); + +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é', + combautre: 'foyer fermé bois à combustion', + 84: 'chaudière fioul (système collectif par défaut)' + }, + type_generateur_ch: { chaud_ch: 'chaudière gaz condensation' }, + classe_altitude: { 1: 'ca1' }, + zone_climatique: { 1: 'h1a' }, + type_installation_solaire: { 1: 'installation solaire' } + } +})); + +vi.mock('./utils.js', () => ({ + get bug_for_bug_compat() { + return state.bug; + }, + 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, tv, getVolumeStockageFromDescription } = + await import('./utils.js'); +const { tv_generateur_combustion } = await import('./13.2_generateur_combustion.js'); +const { scopOrCop } = await import('./12.4_pac.js'); +const { default: getFicheTechnique } = await import('./ficheTechnique.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(); + vi.mocked(tv).mockReset(); + vi.mocked(getVolumeStockageFromDescription).mockReset(); + vi.mocked(getFicheTechnique).mockReset(); + state.bug = false; + + 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); + }); + + /** + * 14.1 - Pertes de stockage forfaitaires (générateur électrique à accumulation). + * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §14.1 + */ + test('électrique avec stockage : pertes de stockage forfaitaires et matcher de volume', () => { + vi.mocked(tvColumnIDs).mockImplementation((table) => + table === 'pertes_stockage' ? ['60'] : [] + ); + vi.mocked(tv).mockReturnValue({ cr: '0.5', tv_pertes_stockage_id: '7' }); + const g = gen({ + type_energie: 'électricité', + enum_type_generateur_ecs_id: '60', + type_stockage_ecs: 'ballon vertical', + volume_stockage: 150 + }); + calc_gen_ecs({}, g, ecs_di, ecs_de, 0, '1', '1', 'maison'); + + // Volume 150 -> plage '100 < ≤ 200' + expect(tv).toHaveBeenCalledWith('pertes_stockage', { + enum_type_generateur_ecs_id: '60', + volume_ballon: '100 < ≤ 200' + }); + // Qgw = (8592 * 45 / 24) * Vs * cr * ratio -- valeur de référence de régression + expect(g.donnee_intermediaire.Qgw).toBe(1208250); + expect(g.donnee_entree.tv_pertes_stockage_id).toBe(7); + // cr est supprimé après usage + expect(g.donnee_intermediaire.cr).toBeUndefined(); + expect(g.donnee_intermediaire.conso_ecs).toBeCloseTo(1319.361111111111, 9); + expect(g.donnee_intermediaire.conso_ecs_depensier).toBeCloseTo(1374.9166666666667, 9); + }); + + test.each([ + [50, '≤ 100'], + [150, '100 < ≤ 200'], + [250, '200 < ≤ 300'], + [400, '> 300'] + ])('plage de volume de ballon : %s L -> %s', (volume, plageAttendue) => { + vi.mocked(tvColumnIDs).mockImplementation((table) => + table === 'pertes_stockage' ? ['60'] : [] + ); + vi.mocked(tv).mockReturnValue({ cr: '0.4', tv_pertes_stockage_id: '7' }); + const g = gen({ + type_energie: 'électricité', + enum_type_generateur_ecs_id: '60', + type_stockage_ecs: 'ballon', + volume_stockage: volume + }); + calc_gen_ecs({}, g, ecs_di, ecs_de, 0, '1', '1', 'maison'); + + expect(tv).toHaveBeenCalledWith('pertes_stockage', { + enum_type_generateur_ecs_id: '60', + volume_ballon: plageAttendue + }); + }); + + test('pertes de stockage introuvables : erreur signalée, cr non renseigné', () => { + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.mocked(tvColumnIDs).mockImplementation((table) => + table === 'pertes_stockage' ? ['60'] : [] + ); + vi.mocked(tv).mockReturnValue(undefined); + const g = gen({ + type_energie: 'électricité', + enum_type_generateur_ecs_id: '60', + type_stockage_ecs: 'ballon', + volume_stockage: 150 + }); + calc_gen_ecs({}, g, ecs_di, ecs_de, 0, '1', '1', 'maison'); + + expect(errSpy).toHaveBeenCalledWith('!! pas de valeur forfaitaire trouvée pour cr !!'); + errSpy.mockRestore(); + }); + + /** + * 17.2.1 - Traitement des usages collectifs (ratio de virtualisation, volume issu de la fiche). + */ + test('installation collective : volume de stockage lu dans la fiche technique', () => { + vi.mocked(getFicheTechnique).mockReturnValue({ valeur: '150 L' }); + ecs_de.enum_type_installation_id = '2'; + ecs_de.ratio_virtualisation = 1; + const g = gen({ + type_energie: 'gaz', + enum_type_generateur_ecs_id: 'autre', + type_stockage_ecs: 'ballon', + volume_stockage: 999 + }); + calc_gen_ecs({}, g, ecs_di, ecs_de, 0, '1', '1', 'immeuble'); + + // Vs issu de la fiche (150) -> Qgw = 67662 * 150^0.55 (générateur non électrique) + expect(g.donnee_intermediaire.Qgw).toBeCloseTo(1064620.987190969, 9); + }); + + test('installation collective sans volume : repli sur parseFloat(Vs) / ratio', () => { + // Fiche dont la valeur ne contient pas de volume exploitable -> VsFiche = 0 + vi.mocked(getFicheTechnique).mockReturnValue({ valeur: 'inconnu' }); + ecs_de.enum_type_installation_id = '2'; + ecs_de.ratio_virtualisation = 2; + const g = gen({ + type_energie: 'gaz', + enum_type_generateur_ecs_id: 'autre', + type_stockage_ecs: 'ballon', + volume_stockage: 0 + }); + calc_gen_ecs({}, g, ecs_di, ecs_de, 0, '1', '1', 'immeuble'); + + // Vs = 0 -> Qgw = 67662 * 0^0.55 = 0 + expect(g.donnee_intermediaire.Qgw).toBe(0); + }); + + test('bug_for_bug_compat : volume proratisé recalé sur la description', () => { + state.bug = true; + vi.mocked(getVolumeStockageFromDescription).mockReturnValue(100); + const g = gen({ + type_energie: 'gaz', + enum_type_generateur_ecs_id: 'autre', + type_stockage_ecs: 'ballon', + volume_stockage: 100 + }); + // ratio = 1 (non collectif) -> round(100 / 1) = 100 == description -> Vs = 100 + calc_gen_ecs({}, g, ecs_di, ecs_de, 0, '1', '1', 'maison'); + + // Générateur non électrique -> Qgw = 67662 * 100^0.55 + expect(g.donnee_intermediaire.Qgw).toBeCloseTo(67662 * 100 ** 0.55, 9); + }); + + test('bug_for_bug_compat : type 84 électrique déduit de l’énergie, pertes via tv_pertes_stockage_id', () => { + state.bug = true; + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.mocked(tv).mockReturnValue({ cr: '0.5', tv_pertes_stockage_id: '9' }); + const g = gen({ + type_energie: 'électricité', + enum_type_generateur_ecs_id: '84', + enum_type_energie_id: '1', + type_stockage_ecs: 'ballon', + volume_stockage: 150, + tv_pertes_stockage_id: '9' + }); + calc_gen_ecs({}, g, ecs_di, ecs_de, 0, '1', '1', 'maison'); + + // Type 84 : le matcher des pertes se fonde sur tv_pertes_stockage_id + expect(tv).toHaveBeenCalledWith('pertes_stockage', { tv_pertes_stockage_id: '9' }); + expect(errSpy).toHaveBeenCalled(); + errSpy.mockRestore(); + }); + + /** + * 18.4 - Facteur de couverture solaire forfaitaire. + */ + test('couverture solaire forfaitaire (maison) : facteur issu de la table', () => { + vi.mocked(tv).mockReturnValue({ + facteur_couverture_solaire: '0.4', + tv_facteur_couverture_solaire_id: '3' + }); + ecs_de.enum_type_installation_solaire_id = '1'; + 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(tv).toHaveBeenCalledWith('facteur_couverture_solaire', { + enum_zone_climatique_id: '1', + type_installation_solaire: 'installation solaire', + type_batiment: 'maison' + }); + expect(ecs_di.fecs).toBe(0.4); + expect(ecs_de.tv_facteur_couverture_solaire_id).toBe(3); + }); + + test('couverture solaire forfaitaire (immeuble) : type de bâtiment immeuble', () => { + vi.mocked(tv).mockReturnValue({ + facteur_couverture_solaire: '0.3', + tv_facteur_couverture_solaire_id: '4' + }); + ecs_de.enum_type_installation_solaire_id = '1'; + const g = gen({ type_energie: 'électricité', enum_type_generateur_ecs_id: '60' }); + calc_gen_ecs({}, g, ecs_di, ecs_de, 0, '1', '1', 'immeuble'); + + expect(tv).toHaveBeenCalledWith( + 'facteur_couverture_solaire', + expect.objectContaining({ type_batiment: 'immeuble' }) + ); + }); + + test('couverture solaire introuvable : erreur signalée', () => { + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.mocked(tv).mockReturnValue(undefined); + ecs_de.enum_type_installation_solaire_id = '1'; + 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(errSpy).toHaveBeenCalledWith( + '!! pas de valeur forfaitaire trouvée pour facteur_couverture_solaire !!' + ); + errSpy.mockRestore(); + }); + + /** + * 14.1.2 / 14.1.3 - Générateurs à combustion (chaudière, accumulateur). + */ + test('chaudière combustion sans stockage (Qgw = 0) : rendement de génération direct', () => { + vi.mocked(tvColumnIDs).mockImplementation((table) => + table === 'generateur_combustion' ? ['chaud'] : [] + ); + vi.mocked(tv_generateur_combustion).mockImplementation((dpe, di) => { + di.rpn = 0.9; + di.qp0 = 100; + di.pn = 20000; + di.pveilleuse = 0; // absence de veilleuse -> pveil forcé à 0 + }); + const g = gen({ + type_energie: 'gaz', + enum_type_generateur_ecs_id: 'chaud', + enum_methode_saisie_carac_sys_id: '1' + }); + calc_gen_ecs({}, g, ecs_di, ecs_de, 0, '1', '1', 'maison'); + + // pveil ramené à 0 en l'absence de veilleuse + expect(g.donnee_intermediaire.pveil).toBe(0); + // valeurs de référence de régression + expect(g.donnee_intermediaire.rendement_generation).toBeCloseTo(0.3446955189582535, 9); + expect(g.donnee_intermediaire.conso_ecs).toBeCloseTo(322.3456790123457, 9); + }); + + test('chaudière combustion avec stockage (Qgw > 0) : rendement génération + stockage', () => { + vi.mocked(tvColumnIDs).mockImplementation((table) => + table === 'generateur_combustion' ? ['chaud'] : [] + ); + vi.mocked(tv_generateur_combustion).mockImplementation((dpe, di) => { + di.rpn = 0.9; + di.qp0 = 100; + di.pn = 20000; + di.pveilleuse = 1; + di.pveil = 0; + }); + const g = gen({ + type_energie: 'gaz', + enum_type_generateur_ecs_id: 'chaud', + enum_methode_saisie_carac_sys_id: '1', + type_stockage_ecs: 'ballon', + volume_stockage: 200 + }); + calc_gen_ecs({}, g, ecs_di, ecs_de, 0, '1', '1', 'maison'); + + expect(g.donnee_intermediaire.Qgw).toBeCloseTo(1247128.8571714761, 9); + // valeur de référence de régression + expect(g.donnee_intermediaire.rendement_generation_stockage).toBeCloseTo( + 0.06505165235309393, + 9 + ); + expect(g.donnee_intermediaire.conso_ecs).toBeCloseTo(1708.0444092028745, 9); + }); + + test('accumulateur gaz : qp0 recalculé (1.5 % de Pn) et rendement dédié', () => { + vi.mocked(tvColumnIDs).mockImplementation((table) => + table === 'generateur_combustion' ? ['accu'] : [] + ); + vi.mocked(tv_generateur_combustion).mockImplementation((dpe, di) => { + di.rpn = 0.9; + di.qp0 = 999; // sera écrasé par 1.5 * pn / 100 + di.pn = 20000; + di.pveilleuse = 1; + di.pveil = 0; + }); + const g = gen({ + type_energie: 'gaz', + enum_type_generateur_ecs_id: 'accu', + enum_methode_saisie_carac_sys_id: '1' + }); + calc_gen_ecs({}, g, ecs_di, ecs_de, 0, '1', '1', 'maison'); + + // qp0 = 1.5 * 20000 / 100 = 300 + expect(g.donnee_intermediaire.qp0).toBe(300); + // valeur de référence de régression + expect(g.donnee_intermediaire.rendement_generation).toBeCloseTo(0.03719254165564666, 9); + }); + + test('générateur à combustion non implémenté : avertissement, aucun rendement', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.mocked(tvColumnIDs).mockImplementation((table) => + table === 'generateur_combustion' ? ['combautre'] : [] + ); + vi.mocked(tv_generateur_combustion).mockImplementation((dpe, di) => { + di.rpn = 0.9; + di.qp0 = 100; + di.pn = 20000; + di.pveilleuse = 1; + di.pveil = 0; + }); + const g = gen({ + type_energie: 'gaz', + enum_type_generateur_ecs_id: 'combautre', + enum_methode_saisie_carac_sys_id: '1' + }); + calc_gen_ecs({}, g, ecs_di, ecs_de, 0, '1', '1', 'maison'); + + expect(warnSpy).toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + test('bug_for_bug_compat : correction de qp0 (< 1) pour un générateur à combustion', () => { + state.bug = true; + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.mocked(tvColumnIDs).mockImplementation((table) => + table === 'generateur_combustion' ? ['chaud'] : [] + ); + vi.mocked(tv_generateur_combustion).mockImplementation((dpe, di) => { + di.rpn = 0.9; + di.qp0 = 0.1; // < 1 -> corrigé à 100 + di.pn = 20000; + di.pveilleuse = 1; + di.pveil = 0; + }); + const g = gen({ + type_energie: 'gaz', + enum_type_generateur_ecs_id: 'chaud', + enum_methode_saisie_carac_sys_id: '1' + }); + calc_gen_ecs({}, g, ecs_di, ecs_de, 0, '1', '1', 'maison'); + + expect(g.donnee_intermediaire.qp0).toBe(100); + expect(g.donnee_intermediaire.rendement_generation).toBeCloseTo(0.3446955189582535, 9); + warnSpy.mockRestore(); + }); + + /** + * Type 84 (système collectif par défaut) : détection combustion / PAC selon les données présentes. + */ + test('type 84 combustion : le type de générateur ECS est déduit de la ligne combustion', () => { + vi.mocked(tv).mockReturnValue({ enum_type_generateur_ecs_id: 'chaud|autre' }); + vi.mocked(tv_generateur_combustion).mockImplementation((dpe, di) => { + di.rpn = 0.9; + di.qp0 = 100; + di.pn = 20000; + di.pveilleuse = 1; + di.pveil = 0; + }); + const g = gen({ + type_energie: 'gaz', + enum_type_generateur_ecs_id: '84', + tv_generateur_combustion_id: '42', + enum_methode_saisie_carac_sys_id: '1' + }); + calc_gen_ecs({}, g, ecs_di, ecs_de, 0, '1', '1', 'maison'); + + // Premier type de la liste retenu pour le calcul du rendement + expect(g.donnee_entree.enum_type_generateur_ecs_id).toBe('chaud'); + // La chaudière est bien calculée + expect(g.donnee_intermediaire.rendement_generation).toBeCloseTo(0.3446955189582535, 9); + }); + + test('type 84 combustion : ligne combustion absente, aucun redressement du type', () => { + vi.mocked(tv).mockReturnValue(undefined); + const g = gen({ + type_energie: 'gaz', + enum_type_generateur_ecs_id: '84', + tv_generateur_combustion_id: '42', + enum_methode_saisie_carac_sys_id: '1' + }); + // Générateur '84' n'est pas dans les combustion_ids : bascule sur la branche neutre (Iecs = 1) + vi.mocked(tv_generateur_combustion).mockImplementation((dpe, di) => { + di.rpn = 0.9; + di.qp0 = 100; + di.pn = 20000; + di.pveilleuse = 1; + }); + calc_gen_ecs({}, g, ecs_di, ecs_de, 0, '1', '1', 'maison'); + + // Type inchangé (pas de enum_type_generateur_ecs_id dans la ligne) + expect(g.donnee_entree.enum_type_generateur_ecs_id).toBe('84'); + }); + + test('type 84 pompe à chaleur : déduit via tv_scop_id', () => { + vi.mocked(scopOrCop).mockImplementation((di) => { + di.scop = 3; + }); + const g = gen({ + type_energie: 'gaz', + enum_type_generateur_ecs_id: '84', + tv_scop_id: '5', + enum_methode_saisie_carac_sys_id: '1' + }); + calc_gen_ecs({}, g, ecs_di, ecs_de, 0, '1', '1', 'maison'); + + // Reconnu comme PAC -> consommation pilotée par le SCOP + expect(scopOrCop).toHaveBeenCalled(); + expect(g.donnee_intermediaire.conso_ecs).toBeCloseTo(37.03703703703703, 9); + }); + + /** + * Réseau de chaleur : redressement de reseau_distribution_isole (bug_for_bug_compat). + */ + test('bug_for_bug_compat : rendement 0.9 saisi force reseau_distribution_isole à 1', () => { + state.bug = true; + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + ecs_de.enum_type_installation_id = '3'; + ecs_de.reseau_distribution_isole = 0; + const g = { + donnee_entree: { + usage_generateur: 'ecs', + type_stockage_ecs: "abscence de stockage d'ecs (production instantanée)", + volume_stockage: 0, + type_energie: 'réseau de chauffage urbain', + enum_type_generateur_ecs_id: '72' + }, + donnee_intermediaire: { rendement_generation_stockage: 0.9 } + }; + calc_gen_ecs({}, g, ecs_di, ecs_de, 0, '1', '1', 'immeuble'); + + expect(ecs_de.reseau_distribution_isole).toBe(1); + expect(warnSpy).toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + test('usage chauffage + ecs : type de générateur limité aux générateurs mixtes', () => { + const g = gen({ + type_energie: 'électricité', + enum_type_generateur_ecs_id: '60', + usage_generateur: 'chauffage + ecs' + }); + calc_gen_ecs({}, g, ecs_di, ecs_de, 0, '1', '1', 'maison'); + + // Le générateur électrique conserve son calcul de rendement de stockage + expect(g.donnee_intermediaire.rendement_stockage).toBeCloseTo(1, 9); + expect(g.donnee_intermediaire.conso_ecs).toBeCloseTo(111.11111111111111, 9); + }); + + test('usage de générateur inconnu : avertissement, type de générateur non défini', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const g = gen({ + type_energie: 'électricité', + enum_type_generateur_ecs_id: '60', + usage_generateur: 'refroidissement' + }); + calc_gen_ecs({}, g, ecs_di, ecs_de, 0, '1', '1', 'maison'); + + expect(warnSpy).toHaveBeenCalledWith( + "!! usage_generateur n'est pas 'ecs' ou 'chauffage + ecs' !!" + ); + warnSpy.mockRestore(); + }); + + test('collectif électrique : pertes de stockage calculées avec le volume collectif', () => { + vi.mocked(tvColumnIDs).mockImplementation((table) => + table === 'pertes_stockage' ? ['60'] : [] + ); + vi.mocked(tv).mockReturnValue({ cr: '0.5', tv_pertes_stockage_id: '7' }); + ecs_de.enum_type_installation_id = '2'; + ecs_de.ratio_virtualisation = 1; + const g = gen({ + type_energie: 'électricité', + enum_type_generateur_ecs_id: '60', + type_stockage_ecs: 'ballon', + volume_stockage: 150 + }); + calc_gen_ecs({}, g, ecs_di, ecs_de, 0, '1', '1', 'immeuble'); + + // Volume collectif (150) transmis à tv_pertes_stockage + expect(tv).toHaveBeenCalledWith('pertes_stockage', { + enum_type_generateur_ecs_id: '60', + volume_ballon: '100 < ≤ 200' + }); + expect(g.donnee_intermediaire.Qgw).toBe(1208250); + }); + + test('pompe à chaleur sans SCOP : la consommation est pilotée par le COP', () => { + vi.mocked(tvColumnIDs).mockImplementation((table) => (table === 'scop' ? ['pac'] : [])); + vi.mocked(scopOrCop).mockImplementation((di) => { + di.cop = 2.5; + }); + const g = gen({ type_energie: 'électricité', enum_type_generateur_ecs_id: 'pac' }); + calc_gen_ecs({}, g, ecs_di, ecs_de, 0, '1', '1', 'maison'); + + // valeur de référence de régression : 100 / 2.5 / 0.9 + expect(g.donnee_intermediaire.conso_ecs).toBeCloseTo(44.44444444444444, 9); + }); + + test('bug_for_bug_compat : rendement 0.75 saisi avec réseau marqué isolé -> avertissement', () => { + state.bug = true; + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + ecs_de.enum_type_installation_id = '3'; + ecs_de.reseau_distribution_isole = 1; + const g = { + donnee_entree: { + usage_generateur: 'ecs', + type_stockage_ecs: "abscence de stockage d'ecs (production instantanée)", + volume_stockage: 0, + type_energie: 'réseau de chauffage urbain', + enum_type_generateur_ecs_id: '72', + description: 'RCU' + }, + donnee_intermediaire: { rendement_generation_stockage: 0.75 } + }; + calc_gen_ecs({}, g, ecs_di, ecs_de, 0, '1', '1', 'immeuble'); + + // Le réseau reste isolé (0.9) car reseau_distribution_isole = 1 + expect(g.donnee_intermediaire.rendement_generation_stockage).toBe(0.9); + expect(warnSpy).toHaveBeenCalled(); + warnSpy.mockRestore(); + }); +}); diff --git a/src/15_conso_aux.spec.js b/src/15_conso_aux.spec.js new file mode 100644 index 00000000..f521dfce --- /dev/null +++ b/src/15_conso_aux.spec.js @@ -0,0 +1,225 @@ +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); + }); + + test('générateur à air chaud avec Pn > 300 kW : puissance plafonnée à 300 kW', () => { + // enum_type_generateur_ch_id = 50 => générateur à air chaud (H=4), G=0 + const diAir = { pn: 500000 }; + conso_aux_gen(diAir, { enum_type_generateur_ch_id: '50' }, 'ch', 1000, 1200, 100); + // g=0, h=4, pe plafonné à 300000 => Paux = 0 + 4*300 = 1200 + // conso = (1 * (1200 * 1000 * 1)) / 300000 = 4 + expect(diAir.conso_auxiliaire_generation_ch).toBeCloseTo(4, 10); + // depensier utilise di.pn (500000, non plafonné) : (1200 * 1200) / 500000 = 2.88 + expect(diAir.conso_auxiliaire_generation_ch_depensier).toBeCloseTo(2.88, 10); + }); + + test('chaudière bois avec ventilateur et Pn > 70 kW : puissance plafonnée à 70 kW', () => { + // enum_type_generateur_ch_id = 60 => chaudière bois (G=73.3, H=10.5) + const diBois = { pn: 100000 }; + conso_aux_gen( + diBois, + { enum_type_generateur_ch_id: '60', presenceVentilateur: 1 }, + 'ch', + 1000, + 1200, + 100 + ); + // pe plafonné à 70000 => Paux = 73.3 + 10.5*70 = 808.3 + // conso = (1 * (808.3 * 1000 * 1)) / 70000 + expect(diBois.conso_auxiliaire_generation_ch).toBeCloseTo((808.3 * 1000) / 70000, 10); + }); + + test('type de générateur inconnu (ni ch ni ecs) : facteurs nuls, consommation nulle', () => { + // Couvre la branche de repli `values[type] || []` de getFacteur + const diInconnu = { pn: 20000 }; + conso_aux_gen(diInconnu, {}, 'autre', 1000, 1200, 100); + expect(diInconnu.conso_auxiliaire_generation_autre).toBe(0); + expect(diInconnu.conso_auxiliaire_generation_autre_depensier).toBe(0); + }); +}); + +/** + * 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); + }); + + test('émetteur plancher/plafond chauffant (deltaPem=15, Fcot=0,156)', () => { + // Type 6 => première liste : deltaPem=15, Fcot=0,156, deltaDim=7,5 (temp id 3) + const em = [ + { + donnee_entree: { + enum_type_emission_distribution_id: '6', + enum_temp_distribution_ch_id: '3' + } + } + ]; + const di = {}; + conso_aux_distribution_ch(em, {}, di, {}, 100, 1, 1, '0', 100000); + // valeur de référence de régression + expect(di.conso_auxiliaire_distribution_ch).toBeCloseTo(215.1781621610199, 9); + }); + + test('émetteur radiateur monotube (deltaPem=30)', () => { + // Type 24 => deuxième liste : deltaPem=30 + const em = [ + { + donnee_entree: { + enum_type_emission_distribution_id: '24', + enum_temp_distribution_ch_id: '3' + } + } + ]; + const di = {}; + conso_aux_distribution_ch(em, {}, di, {}, 100, 1, 1, '0', 100000); + // valeur de référence de régression + expect(di.conso_auxiliaire_distribution_ch).toBeCloseTo(372.082418731648, 9); + }); + + test('plusieurs émetteurs : Fcot forcé à 0,802 (cas le plus défavorable)', () => { + // Deux émetteurs de type plancher chauffant : sans le forçage, Fcot=0,156. + const em = [ + { + donnee_entree: { + enum_type_emission_distribution_id: '6', + enum_temp_distribution_ch_id: '3' + } + }, + { + donnee_entree: { + enum_type_emission_distribution_id: '6', + enum_temp_distribution_ch_id: '3' + } + } + ]; + const di = {}; + conso_aux_distribution_ch(em, {}, di, {}, 100, 1, 1, '0', 100000); + // valeur de référence de régression (Fcot=0,802 malgré des planchers chauffants) + expect(di.conso_auxiliaire_distribution_ch).toBeCloseTo(260.5604692803713, 9); + }); + + test('température de distribution haute (id 4) : deltaDim = 15', () => { + const em = [ + { + donnee_entree: { + enum_type_emission_distribution_id: '10', + enum_temp_distribution_ch_id: '4' + } + } + ]; + const di = {}; + conso_aux_distribution_ch(em, {}, di, {}, 100, 1, 1, '0', 100000); + // valeur de référence de régression + expect(di.conso_auxiliaire_distribution_ch).toBeCloseTo(136.51553416032854, 9); + }); +}); diff --git a/src/16.2_production_enr.spec.js b/src/16.2_production_enr.spec.js index c5624ed8..d04d6b74 100644 --- a/src/16.2_production_enr.spec.js +++ b/src/16.2_production_enr.spec.js @@ -249,4 +249,192 @@ describe('production ENR unit tests', () => { } }); }); + + test('should reuse an existing donnee_intermediaire when computing conso elec ac', () => { + // Le noeud donnee_intermediaire préexiste : il doit être conservé (et non recréé) + const productionElecEnr = { + donnee_intermediaire: { valeur_existante: 42 }, + panneaux_pv_collection: { + panneaux_pv: [{ nombre_module: 8, enum_orientation_pv_id: 1, enum_inclinaison_pv_id: 2 }] + } + }; + + const productionElectricite = {}; + const conso = { + ef_conso: {}, + sortie_par_energie_collection: { + sortie_par_energie: [ + { enum_type_energie_id: '1', conso_5_usages: 1500, conso_ch: 1000, conso_ecs: 1500 } + ] + } + }; + + productionENR.calculateConsoElecAc( + productionElectricite, + productionElecEnr, + conso, + 1, + 'maison', + 10 + ); + + // la clé préexistante est préservée, les champs calculés sont ajoutés + expect(productionElecEnr.donnee_intermediaire.valeur_existante).toBe(42); + expect(productionElecEnr.donnee_intermediaire.production_pv).toBe(2826.8015616000007); + expect(productionElecEnr.donnee_intermediaire.conso_elec_ac).toBeGreaterThan(0); + }); + + test('should get ppv when panneaux_pv is a single object (not an array)', () => { + const productionElecEnr = { + panneaux_pv_collection: { + panneaux_pv: { + nombre_module: 8, + enum_orientation_pv_id: 1, + enum_inclinaison_pv_id: 2 + } + } + }; + // même résultat qu'avec un tableau à un élément + expect(productionENR.getPpv(productionElecEnr, 1)).toBe(2826.8015616000007); + }); + + test('should update ep conso with a coeff ep override', () => { + const productionElectricite = { + conso_elec_ac_ecs: 200, + conso_elec_ac_ch: 150, + conso_elec_ac_fr: 100, + conso_elec_ac_eclairage: 250, + conso_elec_ac_auxiliaire: 300 + }; + const conso = { + ep_conso: { + ep_conso_ecs: 1000, + ep_conso_ch: 500, + ep_conso_fr: 800, + ep_conso_eclairage: 900, + ep_conso_totale_auxiliaire: 1250, + ep_conso_5_usages: 5000, + ep_conso_5_usages_m2: 100 + } + }; + + // coeff_ep_override = 2 : chaque poste est minoré de 2 * autoconso + productionENR.updateEPConso(productionElectricite, conso, 10, 2); + + expect(conso.ep_conso).toStrictEqual({ + ep_conso_ecs: 600, // 1000 - 2*200 + ep_conso_ch: 200, // 500 - 2*150 + ep_conso_fr: 600, // 800 - 2*100 + ep_conso_eclairage: 400, // 900 - 2*250 + ep_conso_totale_auxiliaire: 650, // 1250 - 2*300 + ep_conso_5_usages: 3000, // 5000 - 2*1000 + ep_conso_5_usages_m2: 300 // floor(3000 / 10) + }); + }); + + test('should update ep conso with the default coeff ep (1.9) when no override', () => { + const productionElectricite = { + conso_elec_ac_ecs: 200, + conso_elec_ac_ch: 150, + conso_elec_ac_fr: 100, + conso_elec_ac_eclairage: 250, + conso_elec_ac_auxiliaire: 300 + }; + const conso = { + ep_conso: { + ep_conso_ecs: 1000, + ep_conso_ch: 500, + ep_conso_fr: 800, + ep_conso_eclairage: 900, + ep_conso_totale_auxiliaire: 1250, + ep_conso_5_usages: 5000, + ep_conso_5_usages_m2: 100 + } + }; + + // pas d'override => coefficient par défaut 1.9 + productionENR.updateEPConso(productionElectricite, conso, 10); + + expect(conso.ep_conso).toStrictEqual({ + ep_conso_ecs: 620, // 1000 - 1.9*200 + ep_conso_ch: 215, // 500 - 1.9*150 + ep_conso_fr: 610, // 800 - 1.9*100 + ep_conso_eclairage: 425, // 900 - 1.9*250 + ep_conso_totale_auxiliaire: 680, // 1250 - 1.9*300 + ep_conso_5_usages: 3100, // 5000 - 1.9*1000 + ep_conso_5_usages_m2: 310 // floor(3100 / 10) + }); + }); + + test.each([ + ['une installation absente', null], + ["l'absence de production photovoltaïque", { donnee_entree: { presence_production_pv: 0 } }] + ])('calculateEnr retourne des autoconsommations nulles pour %s', (_label, productionElecEnr) => { + const conso = { ef_conso: {}, ep_conso: {} }; + + const ret = productionENR.calculateEnr(productionElecEnr, conso, 10, 'maison', 1, 100); + + expect(ret).toStrictEqual({ + production_pv: 0, + conso_elec_ac: 0, + conso_elec_ac_ch: 0, + conso_elec_ac_ecs: 0, + conso_elec_ac_fr: 0, + conso_elec_ac_eclairage: 0, + conso_elec_ac_auxiliaire: 0, + conso_elec_ac_autre_usage: 0 + }); + }); + + test('calculateEnr calcule et applique les autoconsommations en présence de PV', () => { + const productionElecEnr = { + donnee_entree: { presence_production_pv: 1 }, + panneaux_pv_collection: { + panneaux_pv: [{ nombre_module: 8, enum_orientation_pv_id: 1, enum_inclinaison_pv_id: 2 }] + } + }; + const conso = { + ef_conso: { + conso_ecs: 1000, + conso_ch: 500, + conso_fr: 800, + conso_eclairage: 900, + conso_totale_auxiliaire: 1250, + conso_5_usages: 1500, + conso_5_usages_m2: 100 + }, + ep_conso: { + ep_conso_ecs: 3000, + ep_conso_ch: 1500, + ep_conso_fr: 2000, + ep_conso_eclairage: 1800, + ep_conso_totale_auxiliaire: 2500, + ep_conso_5_usages: 10000, + ep_conso_5_usages_m2: 100 + }, + sortie_par_energie_collection: { + sortie_par_energie: [ + { enum_type_energie_id: '1', conso_5_usages: 1500, conso_ch: 1000, conso_ecs: 1500 } + ] + } + }; + + const ret = productionENR.calculateEnr(productionElecEnr, conso, 10, 'maison', 1, 100); + + // valeurs de référence de régression + expect(ret).toStrictEqual({ + production_pv: 2826.8015616000007, + conso_elec_ac: 403.3632077277818, + conso_elec_ac_ch: 17.146151231786686, + conso_elec_ac_ecs: 64.29806711920007, + conso_elec_ac_fr: 171.46151231786683, + conso_elec_ac_eclairage: 38.57884027152004, + conso_elec_ac_auxiliaire: 0, + conso_elec_ac_autre_usage: 21.14245810055866 + }); + + // les consommations d'énergie finale sont bien minorées par l'autoconsommation + expect(conso.ef_conso.conso_ecs).toBeLessThan(1000); + expect(conso.ef_conso.conso_5_usages).toBeLessThan(1500); + }); }); diff --git a/src/16_conso_eclairage.spec.js b/src/16_conso_eclairage.spec.js new file mode 100644 index 00000000..dc424473 --- /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(); + }); +}); 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 00000000..8f1313e3 --- /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/2021_04_13_qualite_isolation.js b/src/2021_04_13_qualite_isolation.js index cb7a7da2..4e3a4767 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 00000000..2d912312 --- /dev/null +++ b/src/2021_04_13_qualite_isolation.spec.js @@ -0,0 +1,286 @@ +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 sans tableau interne (undefined) : repli sur des listes vides', () => { + // Aucune des collections n'expose son tableau => branches `|| []` empruntées. + const env = { + mur_collection: {}, + plancher_haut_collection: {}, + plancher_bas_collection: {}, + baie_vitree_collection: {}, + porte_collection: {} + }; + 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); + + expect(Number.isFinite(ret.ubat)).toBe(false); + expect(ret.qualite_isol_mur).toBe(1); + expect(ret.qualite_isol_plancher_haut_comble_amenage).toBeUndefined(); + }); + + 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(); + }); +}); diff --git a/src/3.1_b.js b/src/3.1_b.js index 12a3f755..4ab55203 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 00000000..9d16a324 --- /dev/null +++ b/src/3.1_b.spec.js @@ -0,0 +1,367 @@ +import { afterEach, 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. + */ +/** + * `bug_for_bug_compat` est exposé via un accesseur adossé à un état hoisté afin de pouvoir + * l'activer sur les tests reproduisant les comportements bugués des DPE réels. + */ +const state = vi.hoisted(() => ({ bug: false })); + +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], + get bug_for_bug_compat() { + return state.bug; + } +})); + +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'; + +let errorSpy; + +beforeEach(() => { + tv.mockReset(); + state.bug = false; + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); +}); + +afterEach(() => { + errorSpy.mockRestore(); +}); + +/** + * 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(); + }); + + /** + * Configuration "local chauffé non accessible" (cfg_isolation_lnc = 1) : le rapport aiu/aue + * n'est pas connu, l'adjacence est retirée du matcher. + */ + test('local chauffé non accessible (cfg 1) : adjacence retirée du matcher', () => { + tv.mockReturnValue({ b: '0.95', tv_coef_reduction_deperdition_id: '300' }); + const di = {}; + const de = { + enum_type_adjacence_id: '8', + enum_cfg_isolation_lnc_id: '1', + surface_aue: 20 + }; + + b(di, de, {}, 1); + + expect(tv).toHaveBeenCalledWith(TABLE, { enum_cfg_isolation_lnc_id: '1' }); + expect(di.b).toBe(0.95); + }); + + /** + * Rapport surface_aiu / surface_aue égal exactement à la plus petite borne (0,25) : + * seule la borne supérieure aiu_aue_max est renseignée. + */ + test('rapport égal à la borne minimale (0,25) : seule aiu_aue_max est renseignée', () => { + tv.mockReturnValue({ b: '0.85', tv_coef_reduction_deperdition_id: '150' }); + const di = {}; + const de = { + enum_type_adjacence_id: '8', + enum_cfg_isolation_lnc_id: '2', + surface_aiu: 5, + surface_aue: 20 // rapport = 0,25 + }; + + b(di, de, {}, 1); + + expect(tv).toHaveBeenCalledWith(TABLE, { + enum_type_adjacence_id: '8', + enum_cfg_isolation_lnc_id: '2', + aiu_aue_max: '≤ 0,25' + }); + expect(di.b).toBe(0.85); + }); + + /** + * Compatibilité bug : surface_aiu === surface_aue mais le DPE a renseigné des bornes aiu/aue + * via tv_coef_reduction_deperdition_id différentes des bornes par défaut. Ces bornes sont + * conservées (avec émission d'une erreur explicative). + */ + test('compat bug : bornes aiu/aue issues du DPE conservées malgré aiu === aue', () => { + state.bug = true; + tv.mockImplementation((table, matcher) => { + // 1er appel : lecture de la ligne référencée par tv_coef_reduction_deperdition_id + if (matcher.tv_coef_reduction_deperdition_id) { + return { aiu_aue_min: '0,50 <', aiu_aue_max: '≤ 0,75' }; + } + // 2e appel : lecture de la valeur de b avec le matcher final + return { b: '0.8', tv_coef_reduction_deperdition_id: '200' }; + }); + const di = {}; + const de = { + enum_type_adjacence_id: '8', + enum_cfg_isolation_lnc_id: '2', + surface_aiu: 20, + surface_aue: 20, + tv_coef_reduction_deperdition_id: 5, + description: 'paroi test' + }; + + b(di, de, {}, 1); + + expect(tv).toHaveBeenLastCalledWith(TABLE, { + enum_type_adjacence_id: '8', + enum_cfg_isolation_lnc_id: '2', + aiu_aue_min: '0,50 <', + aiu_aue_max: '≤ 0,75' + }); + expect(errorSpy).toHaveBeenCalled(); + expect(di.b).toBe(0.8); + }); + + /** + * Compat bug : la ligne référencée ne fournit pas de bornes (valeurs absentes) : les bornes + * par défaut '0,75 <' et '≤ 1,00' sont conservées et aucune erreur n'est émise. + */ + test('compat bug : bornes absentes dans le DPE, bornes par défaut conservées', () => { + state.bug = true; + tv.mockImplementation((table, matcher) => { + if (matcher.tv_coef_reduction_deperdition_id) { + return { aiu_aue_min: undefined, aiu_aue_max: undefined }; + } + return { b: '0.7', tv_coef_reduction_deperdition_id: '201' }; + }); + const di = {}; + const de = { + enum_type_adjacence_id: '8', + enum_cfg_isolation_lnc_id: '2', + surface_aiu: 20, + surface_aue: 20, + tv_coef_reduction_deperdition_id: 5 + }; + + b(di, de, {}, 1); + + expect(tv).toHaveBeenLastCalledWith(TABLE, { + enum_type_adjacence_id: '8', + enum_cfg_isolation_lnc_id: '2', + aiu_aue_min: '0,75 <', + aiu_aue_max: '≤ 1,00' + }); + expect(errorSpy).not.toHaveBeenCalled(); + expect(di.b).toBe(0.7); + }); + + /** + * Compat bug : aucune ligne référencée retournée (tv_coef_reduction_deperdition_id présent + * mais table vide) : les bornes par défaut sont conservées. + */ + test('compat bug : aucune ligne référencée, bornes par défaut conservées', () => { + state.bug = true; + tv.mockImplementation((table, matcher) => { + if (matcher.tv_coef_reduction_deperdition_id) return null; + return { b: '0.72', tv_coef_reduction_deperdition_id: '202' }; + }); + const di = {}; + const de = { + enum_type_adjacence_id: '8', + enum_cfg_isolation_lnc_id: '2', + surface_aiu: 20, + surface_aue: 20, + tv_coef_reduction_deperdition_id: 5 + }; + + b(di, de, {}, 1); + + expect(tv).toHaveBeenLastCalledWith(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.72); + }); + + /** + * Compat bug activée mais tv_coef_reduction_deperdition_id absent : le bloc de reprise n'est + * pas exécuté, les bornes par défaut sont utilisées directement. + */ + test('compat bug sans tv_coef_reduction_deperdition_id : bornes par défaut', () => { + state.bug = true; + tv.mockReturnValue({ b: '0.7', tv_coef_reduction_deperdition_id: '203' }); + 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); + }); +}); diff --git a/src/3.2.1_mur.spec.js b/src/3.2.1_mur.spec.js index 2452d180..1fd32176 100644 --- a/src/3.2.1_mur.spec.js +++ b/src/3.2.1_mur.spec.js @@ -1,47 +1,820 @@ -import calc_mur from './3.2.1_mur.js'; -import { describe, expect, test } from 'vitest'; - -describe('Recherche de bugs dans le calcul de déperdition des murs', () => { - /** - * @see : https://redfroggy.atlassian.net/browse/KAR-119 - */ - test('calcul de déperdition pour les murs de 2213E0696993Z', () => { - const zc = 8; // H3 - const pc_id = 2; // Période de construction (1948) - const ej = 0; - const mur = { - donnee_entree: { - description: - "Mur 2 Est - Inconnu donnant sur des circulations sans ouverture directe sur l'extérieur", - reference: '2021_08_24_18_02_58_7233440008111783', - tv_coef_reduction_deperdition_id: 78, - surface_aiu: 22, - surface_aue: 15, - enum_cfg_isolation_lnc_id: '2', - enum_type_adjacence_id: '14', // Circulation sans ouverture directe sur l'extérieur - enum_orientation_id: '3', // Est - surface_paroi_totale: 10.5, - surface_paroi_opaque: 10.5, +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +/** + * Dépendances mockées pour isoler `calc_mur` : + * - `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 Umur ici) ; + * - `tv` : accès aux tables `umur0` / `umur`, on contrôle la ligne retournée par table ; + * - `requestInput` / `requestInputID` : passe-plats vers les données d'entrée ; + * - `getKeyByValue` : implémentation déterministe (indépendante des vraies enums) ; + * - `getThicknessFromDescription` : contrôlé par test (épaisseur extraite de la description) ; + * - `bug_for_bug_compat` : exposé via un getter afin de pouvoir le basculer par test. + */ +let bugForBugCompat = false; + +vi.mock('./enums.js', () => ({ + default: { + periode_construction: { 1: 'avant 1948', 3: '1948-1974', 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], + requestInputID: (de, du, field) => de[`enum_${field}_id`], + getKeyByValue: (object, value) => Object.keys(object).find((key) => object[key] === value), + getThicknessFromDescription: vi.fn(() => 0), + get bug_for_bug_compat() { + return bugForBugCompat; + } +})); + +const { default: calc_mur } = await import('./3.2.1_mur.js'); +const { tv, getThicknessFromDescription } = await import('./utils.js'); + +/** Table `umur0` figée : U0 forfaitaire de la paroi nue. */ +const ROW_UMUR0 = { umur0: '2', tv_umur0_id: '1' }; +/** Table `umur` figée : U forfaitaire de la paroi isolée. */ +const ROW_UMUR = { umur: '0.5', tv_umur_id: '6' }; + +beforeEach(() => { + bugForBugCompat = false; + tv.mockReset(); + getThicknessFromDescription.mockReset(); + getThicknessFromDescription.mockReturnValue(0); + tv.mockImplementation((table) => { + if (table === 'umur0') return { ...ROW_UMUR0 }; + if (table === 'umur') return { ...ROW_UMUR }; + return null; + }); +}); + +/** + * 3.2.1 Coefficient de transmission des murs (Umur) + * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §3.2.1 + */ +describe('calc_mur - mur non isolé', () => { + test('type de paroi inconnu : Umur0 = 2,5 et Umur = min(Umur0 ; 2,5)', () => { + const mur = { + donnee_entree: { + methode_saisie_u: 'non isolé', + methode_saisie_u0: 'type de paroi inconnu (valeur par défaut)', + enum_type_doublage_id: '2' + } + }; + + calc_mur(mur, 'h1a', '1', '0'); + + expect(mur.donnee_intermediaire.umur0).toBe(2.5); + expect(mur.donnee_intermediaire.umur).toBe(2.5); + }); +}); + +describe('calc_mur - calcul de Umur0 par table forfaitaire (tv_umur0)', () => { + test('matériau concerné par les épaisseurs : épaisseur intégrée au matcher', () => { + const mur = { + donnee_entree: { + methode_saisie_u: 'non isolé', + methode_saisie_u0: + 'déterminé selon le matériau et épaisseur à partir de la table de valeur forfaitaire', + enum_materiaux_structure_mur_id: '5', + epaisseur_structure: 30, + enum_type_doublage_id: '2' + } + }; + + calc_mur(mur, 'h1a', '1', '0'); + + expect(tv).toHaveBeenCalledWith('umur0', { + enum_materiaux_structure_mur_id: '5', + epaisseur_structure: 30 + }); + expect(mur.donnee_intermediaire.umur0).toBe(2); + expect(mur.donnee_intermediaire.umur).toBe(2); + }); + + test.each([['1'], ['6'], ['20'], ['27']])( + 'matériau %s non concerné par les épaisseurs : matcher sans épaisseur', + (materiau) => { + const mur = { + donnee_entree: { + methode_saisie_u: 'non isolé', + methode_saisie_u0: + 'déterminé selon le matériau et épaisseur à partir de la table de valeur forfaitaire', + enum_materiaux_structure_mur_id: materiau, + epaisseur_structure: 30, + enum_type_doublage_id: '2' + } + }; + + calc_mur(mur, 'h1a', '1', '0'); + + expect(tv).toHaveBeenCalledWith('umur0', { + enum_materiaux_structure_mur_id: materiau + }); + } + ); + + test('épaisseur absente : récupération depuis la description', () => { + getThicknessFromDescription.mockReturnValue(25); + const mur = { + donnee_entree: { + methode_saisie_u: 'non isolé', + methode_saisie_u0: + 'déterminé selon le matériau et épaisseur à partir de la table de valeur forfaitaire', + enum_materiaux_structure_mur_id: '5', + description: 'mur de 25 cm', + enum_type_doublage_id: '2' + } + }; + + calc_mur(mur, 'h1a', '1', '0'); + + expect(getThicknessFromDescription).toHaveBeenCalledWith('mur de 25 cm'); + expect(tv).toHaveBeenCalledWith('umur0', { + enum_materiaux_structure_mur_id: '5', + epaisseur_structure: 25 + }); + }); + + test('bug_for_bug : épaisseur absente et introuvable dans la description, reprise depuis tv_umur0_id', () => { + bugForBugCompat = true; + getThicknessFromDescription.mockReturnValue(0); + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + tv.mockImplementation((table, matcher) => { + if (table === 'umur0' && matcher.tv_umur0_id) { + return { epaisseur_structure: 18, tv_umur0_id: '1' }; + } + if (table === 'umur0') return { ...ROW_UMUR0, tv_umur0_id: matcher.tv_umur0_id ?? '1' }; + return null; + }); + const mur = { + donnee_entree: { + methode_saisie_u: 'non isolé', + methode_saisie_u0: + 'déterminé selon le matériau et épaisseur à partir de la table de valeur forfaitaire', + enum_materiaux_structure_mur_id: '5', + description: 'mur sans épaisseur', tv_umur0_id: 1, - enum_materiaux_structure_mur_id: '1', // Inconnu - enum_methode_saisie_u0_id: '2', // déterminé selon le matériau et épaisseur à partir de la table de valeur forfaitaire - paroi_ancienne: 0, - enum_type_doublage_id: '2', // absence de doublage - enum_type_isolation_id: '1', // inconnu - enum_periode_isolation_id: '2', // 1948-1974 - tv_umur_id: 6, // - enum_methode_saisie_u_id: '8' // année de construction saisie (table forfaitaire) + enum_type_doublage_id: '2' + } + }; + + calc_mur(mur, 'h1a', '1', '0'); + + // L'épaisseur 18 est récupérée depuis la ligne tv_umur0_id puis utilisée dans le matcher final + expect(error).toHaveBeenCalled(); + expect(tv).toHaveBeenLastCalledWith('umur0', { + enum_materiaux_structure_mur_id: '5', + epaisseur_structure: 18 + }); + error.mockRestore(); + }); + + test('bug_for_bug : épaisseur > 80 recalculée en la divisant par 10 (valeur en millimètres)', () => { + bugForBugCompat = true; + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + // du.tv_umur0_id_avant = mur.donnee_entree.tv_umur0_id = 5. + // Le 1er appel (épaisseur 200) renvoie tv_umur0_id 1 (mismatch) => recalcul avec épaisseur 20. + tv.mockImplementation((table, matcher) => { + if (table !== 'umur0') return null; + return { umur0: '2', tv_umur0_id: matcher.epaisseur_structure === 20 ? '5' : '1' }; + }); + const mur = { + donnee_entree: { + methode_saisie_u: 'non isolé', + methode_saisie_u0: + 'déterminé selon le matériau et épaisseur à partir de la table de valeur forfaitaire', + enum_materiaux_structure_mur_id: '5', + epaisseur_structure: 200, + tv_umur0_id: 5, + enum_type_doublage_id: '2', + description: 'mur' + } + }; + + calc_mur(mur, 'h1a', '1', '0'); + + expect(error).toHaveBeenCalledWith(expect.stringContaining('epaisseur > 80')); + // Après division par 10, l'épaisseur 20 donne la bonne ligne (tv_umur0_id 5) + expect(mur.donnee_entree.tv_umur0_id).toBe(5); + expect(mur.donnee_intermediaire.umur0).toBe(2); + error.mockRestore(); + }); + + test('bug_for_bug : épaisseur > 80 toujours incohérente après division : Umur0 non trouvé', () => { + bugForBugCompat = true; + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + // Aucun appel ne renvoie tv_umur0_id = 5 : la ligne finit par être invalidée (row = undefined) + tv.mockImplementation((table) => (table === 'umur0' ? { umur0: '2', tv_umur0_id: '1' } : null)); + const mur = { + donnee_entree: { + methode_saisie_u: 'non isolé', + methode_saisie_u0: + 'déterminé selon le matériau et épaisseur à partir de la table de valeur forfaitaire', + enum_materiaux_structure_mur_id: '5', + epaisseur_structure: 200, + tv_umur0_id: 5, + enum_type_doublage_id: '2', + description: 'mur' + } + }; + + calc_mur(mur, 'h1a', '1', '0'); + + expect(error).toHaveBeenCalledWith(expect.stringContaining('umur0')); + // di.umur0 n'est jamais défini par tv_umur0 => Math.min(2.5, undefined) = NaN + expect(mur.donnee_intermediaire.umur0).toBeNaN(); + error.mockRestore(); + }); + + test('Umur0 introuvable dans la table : erreur émise', () => { + tv.mockImplementation((table) => (table === 'umur0' ? null : { ...ROW_UMUR })); + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + const mur = { + donnee_entree: { + methode_saisie_u: 'non isolé', + methode_saisie_u0: + 'déterminé selon le matériau et épaisseur à partir de la table de valeur forfaitaire', + enum_materiaux_structure_mur_id: '5', + epaisseur_structure: 30, + enum_type_doublage_id: '2' + } + }; + + calc_mur(mur, 'h1a', '1', '0'); + + expect(error).toHaveBeenCalledWith(expect.stringContaining('umur0')); + error.mockRestore(); + }); +}); + +describe('calc_mur - saisie de Umur0 (calc_umur0)', () => { + test('saisie directe de Umur0 : valeur reprise depuis umur0_saisi', () => { + const mur = { + donnee_entree: { + methode_saisie_u: 'non isolé', + methode_saisie_u0: + 'saisie direct u0 justifiée à partir des documents justificatifs autorisés', + umur0_saisi: 1.2, + enum_type_doublage_id: '2' + } + }; + + calc_mur(mur, 'h1a', '1', '0'); + + expect(mur.donnee_intermediaire.umur0).toBe(1.2); + expect(mur.donnee_intermediaire.umur).toBe(1.2); + }); + + test('Umur0 non saisi car U saisi : Umur0 non défini par la méthode', () => { + const mur = { + donnee_entree: { + methode_saisie_u: 'non isolé', + methode_saisie_u0: 'u0 non saisi car le u est saisi connu et justifié.', + enum_type_doublage_id: '2' + } + }; + + calc_mur(mur, 'h1a', '1', '0'); + + // di.umur0 reste indéfini => Math.min(2.5, undefined) = NaN + expect(mur.donnee_intermediaire.umur0).toBeNaN(); + }); + + test('methode_saisie_u0 inconnue : avertissement émis', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const mur = { + donnee_entree: { + methode_saisie_u: 'non isolé', + methode_saisie_u0: 'valeur inexistante', + enum_type_doublage_id: '2' + } + }; + + calc_mur(mur, 'h1a', '1', '0'); + + expect(warn).toHaveBeenCalledWith('methode_saisie_u0 inconnue:', 'valeur inexistante'); + warn.mockRestore(); + }); +}); + +describe('calc_mur - prise en compte du doublage', () => { + test("doublage indéterminé ou lame d'air < 15 mm (type 3) : +0,1 de résistance", () => { + const mur = { + donnee_entree: { + methode_saisie_u: 'non isolé', + methode_saisie_u0: + 'déterminé selon le matériau et épaisseur à partir de la table de valeur forfaitaire', + enum_materiaux_structure_mur_id: '5', + epaisseur_structure: 30, + enum_type_doublage_id: '3' + } + }; + + calc_mur(mur, 'h1a', '1', '0'); + + // Umur0 table = 2 ; 1 / (1/2 + 0,1) = 1,6666... + // valeur de référence de régression + expect(mur.donnee_intermediaire.umur0).toBeCloseTo(1.6666666666666667, 9); + }); + + test.each([['4'], ['5']])( + "doublage avec lame d'air ou connu (type %s) : +0,21 de résistance", + (typeDoublage) => { + const mur = { + donnee_entree: { + methode_saisie_u: 'non isolé', + methode_saisie_u0: + 'déterminé selon le matériau et épaisseur à partir de la table de valeur forfaitaire', + enum_materiaux_structure_mur_id: '5', + epaisseur_structure: 30, + enum_type_doublage_id: typeDoublage + } + }; + + calc_mur(mur, 'h1a', '1', '0'); + + // Umur0 table = 2 ; 1 / (1/2 + 0,21) = 1,40845... + // valeur de référence de régression + expect(mur.donnee_intermediaire.umur0).toBeCloseTo(1.4084507042253522, 9); + } + ); + + test.each([ + ['doublage connu (plâtre, brique...)', 5], + ["doublage indéterminé avec lame d'air", 4], + ["doublage indéterminé ou lame d'air", 3] + ])( + 'bug_for_bug : type de doublage déduit de la description "%s"', + (description, resistanceType) => { + bugForBugCompat = true; + const mur = { + donnee_entree: { + methode_saisie_u: 'non isolé', + methode_saisie_u0: + 'déterminé selon le matériau et épaisseur à partir de la table de valeur forfaitaire', + enum_materiaux_structure_mur_id: '5', + epaisseur_structure: 30, + enum_type_doublage_id: '1', + description + } + }; + + calc_mur(mur, 'h1a', '1', '0'); + + const attendu = resistanceType === 3 ? 1 / (1 / 2 + 0.1) : 1 / (1 / 2 + 0.21); + expect(mur.donnee_intermediaire.umur0).toBeCloseTo(attendu, 9); + } + ); + + test('bug_for_bug : DPE minorant Umur0 à 2,5 avant doublage : avertissement (Umur0 avant)', () => { + bugForBugCompat = true; + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + // umur0_avant est aligné sur le Umur0 minoré à 2,5 avec doublage type 3 => 1/(1/2 + 0,1) + const mur = { + donnee_entree: { + methode_saisie_u: 'non isolé', + methode_saisie_u0: + 'déterminé selon le matériau et épaisseur à partir de la table de valeur forfaitaire', + enum_materiaux_structure_mur_id: '5', + epaisseur_structure: 30, + enum_type_doublage_id: '3' + }, + donnee_intermediaire: { umur0: 1 / (1 / 2 + 0.1) } + }; + + calc_mur(mur, 'h1a', '1', '0'); + + expect(error).toHaveBeenCalledWith(expect.stringContaining('minoré à 2.5')); + error.mockRestore(); + }); + + test('bug_for_bug : DPE minorant Umur0 à 2,5 avant doublage : avertissement (Umur avant)', () => { + bugForBugCompat = true; + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + // umur_avant est aligné sur le Umur0 minoré à 2,5 avec doublage type 4 => 1/(1/2 + 0,21) + const mur = { + donnee_entree: { + methode_saisie_u: 'non isolé', + methode_saisie_u0: + 'déterminé selon le matériau et épaisseur à partir de la table de valeur forfaitaire', + enum_materiaux_structure_mur_id: '5', + epaisseur_structure: 30, + enum_type_doublage_id: '4' + }, + donnee_intermediaire: { umur: 1 / (1 / 2 + 0.21) } + }; + + calc_mur(mur, 'h1a', '1', '0'); + + expect(error).toHaveBeenCalledWith(expect.stringContaining('minoré à 2.5')); + error.mockRestore(); + }); +}); + +describe('calc_mur - enduit isolant sur paroi ancienne', () => { + test('paroi ancienne avec enduit : +0,7 de résistance', () => { + const mur = { + donnee_entree: { + methode_saisie_u: 'non isolé', + methode_saisie_u0: + 'déterminé selon le matériau et épaisseur à partir de la table de valeur forfaitaire', + enum_materiaux_structure_mur_id: '5', + epaisseur_structure: 30, + enum_type_doublage_id: '2', + paroi_ancienne: 1, // renommé en enduit_isolant_paroi_ancienne + enduit_isolant_paroi_ancienne: 1 + } + }; + + calc_mur(mur, 'h1a', '1', '0'); + + // Umur0 = 2 ; 1 / (1/2 + 0,7) = 0,8333... + // valeur de référence de régression + expect(mur.donnee_intermediaire.umur0).toBeCloseTo(0.8333333333333334, 9); + expect(mur.donnee_intermediaire.umur0Enduit).toBeCloseTo(0.8333333333333334, 9); + }); + + test("bug_for_bug : le DPE ignore l'enduit, la valeur Umur0 d'origine est conservée", () => { + bugForBugCompat = true; + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + // umur0_avant = min(2.5, umur0) = 2 (Umur0 table), le DPE n'a pas appliqué l'enduit + const mur = { + donnee_entree: { + methode_saisie_u: 'non isolé', + methode_saisie_u0: + 'déterminé selon le matériau et épaisseur à partir de la table de valeur forfaitaire', + enum_materiaux_structure_mur_id: '5', + epaisseur_structure: 30, + enum_type_doublage_id: '2', + enduit_isolant_paroi_ancienne: 1, + description: 'mur' }, - donnee_intermediaire: { - b: 0.35, - umur: 2.5, - umur0: 2.5 + donnee_intermediaire: { umur0: 2 } + }; + + calc_mur(mur, 'h1a', '1', '0'); + + expect(error).toHaveBeenCalledWith(expect.stringContaining('paroi ancienne')); + // La valeur d'origine (2) est conservée, bornée à 2,5 + expect(mur.donnee_intermediaire.umur0).toBe(2); + error.mockRestore(); + }); +}); + +describe('calc_mur - isolation par épaisseur ou résistance', () => { + test("épaisseur d'isolation saisie : Umur = 1 / (1/Umur0 + e/0,04)", () => { + const mur = { + 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)', + enum_type_doublage_id: '2', + epaisseur_isolation: 10 } }; - calc_mur(mur, zc, pc_id, ej); - expect(mur.donnee_intermediaire.b).toBe(0.35); + calc_mur(mur, 'h1a', '1', '0'); + + // Umur0 = 2,5 ; e = 0,1 ; 1 / (1/2,5 + 0,1/0,04) = 0,3448... + // valeur de référence de régression + expect(mur.donnee_intermediaire.umur).toBeCloseTo(0.3448275862068966, 9); + }); + + test("épaisseur d'isolation absente : repli sur Umur = min(Umur0 ; 2,5) et erreur", () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + const mur = { + donnee_entree: { + methode_saisie_u: + 'epaisseur isolation saisie justifiée à partir des documents justificatifs autorisés', + methode_saisie_u0: 'type de paroi inconnu (valeur par défaut)', + enum_type_doublage_id: '2', + description: 'mur sans épaisseur isolation' + } + }; + + calc_mur(mur, 'h1a', '1', '0'); + + expect(error).toHaveBeenCalledWith(expect.stringContaining('epaisseur_isolation')); expect(mur.donnee_intermediaire.umur).toBe(2.5); - expect(mur.donnee_intermediaire.umur0).toBe(2.5); + error.mockRestore(); + }); + + test("résistance d'isolation saisie : Umur = 1 / (1/Umur0 + R)", () => { + const mur = { + 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)', + enum_type_doublage_id: '2', + resistance_isolation: 1 + } + }; + + calc_mur(mur, 'h1a', '1', '0'); + + // Umur0 = 2,5 ; R = 1 ; 1 / (1/2,5 + 1) = 0,7142... + // valeur de référence de régression + expect(mur.donnee_intermediaire.umur).toBeCloseTo(0.7142857142857143, 9); + }); + + test("résistance d'isolation absente : repli sur Umur = min(Umur0 ; 2,5) et erreur", () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + const mur = { + donnee_entree: { + methode_saisie_u: + 'resistance isolation saisie justifiée à partir des documents justificatifs autorisés', + methode_saisie_u0: 'type de paroi inconnu (valeur par défaut)', + enum_type_doublage_id: '2', + description: 'mur sans résistance isolation' + } + }; + + calc_mur(mur, 'h1a', '1', '0'); + + expect(error).toHaveBeenCalledWith(expect.stringContaining('resistance_isolation')); + expect(mur.donnee_intermediaire.umur).toBe(2.5); + error.mockRestore(); + }); +}); + +describe('calc_mur - table forfaitaire (Umur)', () => { + test('isolation inconnue : Umur = min(Umur table ; Umur0)', () => { + const mur = { + donnee_entree: { + methode_saisie_u: 'isolation inconnue (table forfaitaire)', + methode_saisie_u0: 'type de paroi inconnu (valeur par défaut)', + enum_type_doublage_id: '2' + } + }; + + calc_mur(mur, 'h1a', '1', '0'); + + expect(tv).toHaveBeenCalledWith( + 'umur', + expect.objectContaining({ + enum_periode_construction_id: '1', + enum_zone_climatique_id: 'h1a', + effet_joule: '0' + }), + mur.donnee_entree + ); + // min(0,5 ; 2,5) = 0,5 + expect(mur.donnee_intermediaire.umur).toBe(0.5); + }); + + test("année d'isolation différente : la période d'isolation renseignée est utilisée", () => { + const mur = { + donnee_entree: { + methode_saisie_u: + "année d'isolation différente de l'année de construction saisie justifiée (table forfaitaire)", + methode_saisie_u0: 'type de paroi inconnu (valeur par défaut)', + enum_type_doublage_id: '2', + enum_periode_isolation_id: '6' + } + }; + + calc_mur(mur, 'h1a', '1', '0'); + + expect(tv).toHaveBeenCalledWith( + 'umur', + expect.objectContaining({ enum_periode_construction_id: '6' }), + mur.donnee_entree + ); + }); + + test("année d'isolation différente sans période d'isolation : repli sur la période de construction", () => { + const mur = { + donnee_entree: { + methode_saisie_u: + "année d'isolation différente de l'année de construction saisie justifiée (table forfaitaire)", + methode_saisie_u0: 'type de paroi inconnu (valeur par défaut)', + enum_type_doublage_id: '2' + } + }; + + calc_mur(mur, 'h1a', '5', '0'); + + expect(tv).toHaveBeenCalledWith( + 'umur', + expect.objectContaining({ enum_periode_construction_id: '5' }), + mur.donnee_entree + ); + }); + + test('Umur introuvable dans la table : erreur émise', () => { + tv.mockImplementation((table) => (table === 'umur' ? null : { ...ROW_UMUR0 })); + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + const mur = { + donnee_entree: { + methode_saisie_u: 'isolation inconnue (table forfaitaire)', + methode_saisie_u0: 'type de paroi inconnu (valeur par défaut)', + enum_type_doublage_id: '2' + } + }; + + calc_mur(mur, 'h1a', '1', '0'); + + expect(error).toHaveBeenCalledWith(expect.stringContaining('umur')); + error.mockRestore(); + }); +}); + +describe('calc_mur - année de construction saisie (table forfaitaire)', () => { + test("période d'isolation renseignée : elle est utilisée", () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const mur = { + donnee_entree: { + methode_saisie_u: 'année de construction saisie (table forfaitaire)', + methode_saisie_u0: 'type de paroi inconnu (valeur par défaut)', + enum_type_doublage_id: '2', + enum_periode_isolation_id: '6' + } + }; + + calc_mur(mur, 'h1a', '5', '0'); + + expect(tv).toHaveBeenCalledWith( + 'umur', + expect.objectContaining({ enum_periode_construction_id: '6' }), + mur.donnee_entree + ); + warn.mockRestore(); + }); + + test.each([ + ['1', 'avant 1948'], + ['3', '1948-1974'] + ])( + "construction ancienne (%s = %s) sans année d'isolation : isolation forcée à 1975-1977 + avertissement", + (pcId) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + // pi_id est parseInt(getKeyByValue(...)) = 2 (nombre) ; on renvoie un tv_umur_id différent + tv.mockImplementation((table, matcher) => { + if (table === 'umur0') return { ...ROW_UMUR0 }; + if (table === 'umur') { + return { + umur: '0.5', + tv_umur_id: matcher.enum_periode_construction_id === 2 ? '9' : '6' + }; + } + return null; + }); + const mur = { + donnee_entree: { + methode_saisie_u: 'année de construction saisie (table forfaitaire)', + methode_saisie_u0: 'type de paroi inconnu (valeur par défaut)', + enum_type_doublage_id: '2' + } + }; + + calc_mur(mur, 'h1a', pcId, '0'); + + // parseInt(getKeyByValue(periode_isolation, '1975-1977'), 10) = 2 (nombre) + expect(tv).toHaveBeenCalledWith( + 'umur', + expect.objectContaining({ enum_periode_construction_id: 2 }), + mur.donnee_entree + ); + expect(warn).toHaveBeenCalled(); + warn.mockRestore(); + } + ); + + test('bug_for_bug : second appel forfaitaire avec la période de construction', () => { + bugForBugCompat = true; + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + tv.mockImplementation((table, matcher) => { + if (table === 'umur0') return { ...ROW_UMUR0 }; + // Relecture via tv_umur_id : effet Joule et période cohérents (pas de correction) + if (table === 'umur' && matcher.tv_umur_id) { + return { effet_joule: '0', enum_periode_construction_id: '1' }; + } + if (table === 'umur') { + // 1er appel : période d'isolation (2) => tv_umur_id 9 ; 2e appel bug : période '1' => 3 + return { + umur: '0.5', + tv_umur_id: matcher.enum_periode_construction_id === '1' ? '3' : '9' + }; + } + return null; + }); + const mur = { + donnee_entree: { + methode_saisie_u: 'année de construction saisie (table forfaitaire)', + methode_saisie_u0: 'type de paroi inconnu (valeur par défaut)', + enum_type_doublage_id: '2', + description: 'mur' + } + }; + + calc_mur(mur, 'h1a', '1', '0'); + + // Le dernier appel forfaitaire réutilise la période de construction d'origine ('1') + expect(tv).toHaveBeenLastCalledWith( + 'umur', + expect.objectContaining({ enum_periode_construction_id: '1' }), + mur.donnee_entree + ); + warn.mockRestore(); + }); + + test("construction récente sans année d'isolation : pas de bascule ni d'avertissement", () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const mur = { + donnee_entree: { + methode_saisie_u: 'année de construction saisie (table forfaitaire)', + methode_saisie_u0: 'type de paroi inconnu (valeur par défaut)', + enum_type_doublage_id: '2' + } + }; + + calc_mur(mur, 'h1a', '5', '0'); + + expect(tv).toHaveBeenCalledWith( + 'umur', + expect.objectContaining({ enum_periode_construction_id: '5' }), + mur.donnee_entree + ); + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + }); +}); + +/** + * Compatibilité "bug for bug" du calcul forfaitaire de Umur : la ligne relue via `tv_umur_id` + * peut corriger l'effet Joule et la période de construction utilisés. + */ +describe('calc_mur - bug_for_bug sur tv_umur (récupération depuis tv_umur_id)', () => { + beforeEach(() => { + bugForBugCompat = true; + }); + + test('effet_joule et période de construction du DPE conservés', () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + tv.mockImplementation((table, matcher) => { + if (table === 'umur0') return { ...ROW_UMUR0 }; + if (table === 'umur' && matcher.tv_umur_id) { + return { effet_joule: '1', enum_periode_construction_id: '5|6' }; + } + if (table === 'umur') return { ...ROW_UMUR }; + return null; + }); + const mur = { + donnee_entree: { + methode_saisie_u: 'isolation inconnue (table forfaitaire)', + methode_saisie_u0: 'type de paroi inconnu (valeur par défaut)', + enum_type_doublage_id: '2', + tv_umur_id: 6, + description: 'mur test' + } + }; + + calc_mur(mur, 'h1a', '1', '0'); + + expect(error).toHaveBeenCalled(); + // Le matcher final utilise l'effet Joule ('1') et la période ('5') issus du DPE + expect(tv).toHaveBeenLastCalledWith( + 'umur', + expect.objectContaining({ effet_joule: '1', enum_periode_construction_id: '5' }), + mur.donnee_entree + ); + error.mockRestore(); + }); +}); + +describe('calc_mur - saisie directe de Umur', () => { + test('Umur pris depuis umur_saisi et Umur0 depuis la donnée intermédiaire', () => { + const mur = { + donnee_entree: { + methode_saisie_u: + 'saisie direct u justifiée (à partir des documents justificatifs autorisés)', + umur_saisi: 0.15 + }, + donnee_intermediaire: { umur0: 1.8 } + }; + + calc_mur(mur, 'h1a', '1', '0'); + + expect(mur.donnee_intermediaire.umur).toBe(0.15); + expect(mur.donnee_intermediaire.umur0).toBe(1.8); + }); +}); + +describe('calc_mur - méthode de saisie de U inconnue', () => { + test('aucun calcul de Umur et avertissement émis', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const mur = { donnee_entree: { methode_saisie_u: 'valeur inexistante' } }; + + calc_mur(mur, 'h1a', '1', '0'); + + expect(mur.donnee_intermediaire.umur).toBeUndefined(); + expect(warn).toHaveBeenCalledWith('methode_saisie_u inconnue:', 'valeur inexistante'); + warn.mockRestore(); }); }); diff --git a/src/3.2.2_plancher_bas.spec.js b/src/3.2.2_plancher_bas.spec.js index 01bc11a0..6faf8c33 100644 --- a/src/3.2.2_plancher_bas.spec.js +++ b/src/3.2.2_plancher_bas.spec.js @@ -1,89 +1,654 @@ -import calc_pb from './3.2.2_plancher_bas.js'; -import { describe, expect, test } from 'vitest'; - -describe('Recherche de bugs dans le calcul de déperdition des planchers bas', () => { - describe('calcul de déperdition pour les planchers bas de 2287E1043883T', () => { - test('Mur 1 Nord', () => { - const zc = 3; - const pc_id = 1; - const ej = 0; - const pb1 = { +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +/** + * Dépendances mockées pour isoler `calc_pb` : + * - `enums` : mapping minimal type d'adjacence / 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 Upb ici) ; + * - `tv` : accès aux tables `upb0` / `upb` / `ue`, 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 (indépendante des vraies enums) ; + * - `getRange` : contrôlé par test pour fournir l'encadrement de Upb dans le calcul de Ue ; + * - `bug_for_bug_compat` : exposé via un getter afin de pouvoir le basculer par test. + */ +let bugForBugCompat = false; + +vi.mock('./enums.js', () => ({ + default: { + type_adjacence: { + 5: 'terre-plein', + 6: 'sous-sol non chauffé', + 7: 'vide sanitaire', + 8: 'extérieur' + }, + periode_construction: { 1: 'avant 1948', 3: '1948-1974', 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), + getRange: vi.fn(), + get bug_for_bug_compat() { + return bugForBugCompat; + } +})); + +const { default: calc_pb } = await import('./3.2.2_plancher_bas.js'); +const { tv, getRange } = await import('./utils.js'); + +/** Table `upb0` figée : U0 forfaitaire de la paroi nue. */ +const ROW_UPB0 = { upb0: '2', tv_upb0_id: '9' }; +/** Table `upb` figée : U forfaitaire de la paroi isolée. */ +const ROW_UPB = { upb: '0.3', tv_upb_id: '2' }; + +beforeEach(() => { + bugForBugCompat = false; + tv.mockReset(); + getRange.mockReset(); + tv.mockImplementation((table) => { + if (table === 'upb0') return { ...ROW_UPB0 }; + if (table === 'upb') return { ...ROW_UPB }; + return null; + }); +}); + +/** + * 3.2.2 Coefficient de transmission des planchers bas (Upb) + * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §3.2.2 + */ +describe('calc_pb - plancher non isolé (methode 1)', () => { + test('Upb égal à Upb0 (paroi nue) et adjacence non déperditive => upb_final = upb', () => { + const pb = { + donnee_entree: { + enum_methode_saisie_u_id: '1', + methode_saisie_u0: 'type de paroi inconnu (valeur par défaut)', + enum_type_plancher_bas_id: '9', + type_adjacence: 'extérieur' + } + }; + + calc_pb(pb, 'h1a', '1', '0', [pb]); + + expect(pb.donnee_intermediaire.upb0).toBe(2); + expect(pb.donnee_intermediaire.upb).toBe(2); + expect(pb.donnee_intermediaire.upb_final).toBe(2); + }); + + test('Upb0 initialisé depuis une donnée intermédiaire préexistante', () => { + const pb = { + donnee_entree: { + enum_methode_saisie_u_id: '1', + methode_saisie_u0: 'u0 non saisi car le u est saisi connu et justifié.', + type_adjacence: 'extérieur' + }, + donnee_intermediaire: { upb0: 1.4 } + }; + + calc_pb(pb, 'h1a', '1', '0', [pb]); + + // 'u0 non saisi' ne recalcule pas Upb0 : la valeur préexistante est conservée + expect(pb.donnee_intermediaire.upb0).toBe(1.4); + expect(pb.donnee_intermediaire.upb).toBe(1.4); + }); + + test('Upb0 introuvable dans la table : erreur émise', () => { + tv.mockReturnValue(null); + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + const pb = { + donnee_entree: { + enum_methode_saisie_u_id: '1', + methode_saisie_u0: + 'déterminé selon le matériau et épaisseur à partir de la table de valeur forfaitaire', + enum_type_plancher_bas_id: '9', + type_adjacence: 'extérieur' + } + }; + + calc_pb(pb, 'h1a', '1', '0', [pb]); + + expect(error).toHaveBeenCalledWith(expect.stringContaining('upb0')); + expect(pb.donnee_intermediaire.upb0).toBeUndefined(); + error.mockRestore(); + }); +}); + +describe('calc_pb - calcul de Upb0 (calc_upb0)', () => { + test('saisie directe de Upb0 : valeur reprise depuis upb0_saisi sans accès table', () => { + const pb = { + donnee_entree: { + enum_methode_saisie_u_id: '1', + methode_saisie_u0: + 'saisie direct u0 justifiée à partir des documents justificatifs autorisés', + upb0_saisi: 0.8, + type_adjacence: 'extérieur' + } + }; + + calc_pb(pb, 'h1a', '1', '0', [pb]); + + expect(tv).not.toHaveBeenCalled(); + expect(pb.donnee_intermediaire.upb0).toBe(0.8); + expect(pb.donnee_intermediaire.upb).toBe(0.8); + }); + + test('methode_saisie_u0 inconnue : avertissement émis', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const pb = { + donnee_entree: { + enum_methode_saisie_u_id: '1', + methode_saisie_u0: 'valeur inexistante', + type_adjacence: 'extérieur' + } + }; + + calc_pb(pb, 'h1a', '1', '0', [pb]); + + expect(warn).toHaveBeenCalledWith('methode_saisie_u0 inconnue:', 'valeur inexistante'); + warn.mockRestore(); + }); +}); + +describe('calc_pb - isolation par épaisseur / résistance', () => { + test.each([['3'], ['4']])( + "épaisseur d'isolation saisie (methode %s) : Upb = 1 / (1/Upb0 + e/0,042)", + (methode) => { + const pb = { donnee_entree: { - description: - 'Plancher 1 - Plancher lourd type entrevous terre-cuite, poutrelles béton donnant sur un sous-sol non chauffé avec isolation intrinsèque ou en sous-face (5 cm)', - reference: '2022_05_11_17_10_23_1067244006611344', - tv_coef_reduction_deperdition_id: 6, - enum_type_adjacence_id: '6', - surface_paroi_opaque: 42.08, - tv_upb0_id: 11, - enum_type_plancher_bas_id: '11', - enum_methode_saisie_u0_id: '2', - enum_type_isolation_id: '4', - epaisseur_isolation: 5, - enum_methode_saisie_u_id: '3', - calcul_ue: 1, - perimetre_ue: 27, - surface_ue: 42.08, - ue: 0.37117494 - }, - donnee_intermediaire: { - b: 1, - upb: 0.5915492957746479, - upb_final: 0.37117494, - upb0: 2 + enum_methode_saisie_u_id: methode, + methode_saisie_u0: 'type de paroi inconnu (valeur par défaut)', + enum_type_plancher_bas_id: '9', + epaisseur_isolation: '5', + type_adjacence: 'extérieur' } }; + calc_pb(pb, 'h1a', '1', '0', [pb]); + + // Upb0 = 2 ; e = 5 * 0,01 = 0,05 m ; Upb = 1 / (1/2 + 0,05/0,042) + // valeur de référence de régression + expect(pb.donnee_intermediaire.upb).toBeCloseTo(0.5915492957746479, 9); + } + ); + + test.each([['5'], ['6']])( + "résistance d'isolation saisie (methode %s) : Upb = 1 / (1/Upb0 + R)", + (methode) => { const pb = { donnee_entree: { - description: 'Plancher 2 - Dalle béton donnant sur un terre-plein', - reference: '2022_05_11_17_10_51_2415353009491032', - tv_coef_reduction_deperdition_id: 5, - enum_type_adjacence_id: '5', // Terre-Plein - surface_paroi_opaque: 24.34, - tv_upb0_id: 9, - enum_type_plancher_bas_id: '9', // Dalle béton - enum_methode_saisie_u0_id: '2', // déterminé selon le matériau et épaisseur à partir de la table de valeur forfaitaire - enum_type_isolation_id: '1', // inconnu - enum_periode_isolation_id: '1', // avant 1948 - tv_upb_id: 2, - enum_methode_saisie_u_id: '8', // année de construction saisie (table forfaitaire) - calcul_ue: 1, - perimetre_ue: 22, - surface_ue: 24.34, - ue: 0.61789474 - }, - donnee_intermediaire: { - b: 1, - upb: 2, - upb_final: 0.61789474, - upb0: 2 + enum_methode_saisie_u_id: methode, + methode_saisie_u0: 'type de paroi inconnu (valeur par défaut)', + enum_type_plancher_bas_id: '9', + resistance_isolation: '1', + type_adjacence: 'extérieur' } }; - const pb_list = [pb1, pb]; - - /** - * Sortie lib (différence) - * tv_upb_id: 8 (2 dans dpe origine) - * ue: 0.44 (0.61789474 dans dpe origine) - * upb: 0.9 (2 dans dpe origine) - * upb_final: 0.44 (0.61789474 dans dpe origine) - */ - calc_pb(pb, zc, pc_id, ej, pb_list); - - /** - * P = 22 - * S = 24.34 - * 2S/P = (2 * 24.34) / 22 = 2,212727273 = 2 (arrondi à l'entier le plus proche) - * upb0 = 2 - * upb = Min(upb0; upbtab) = Min(2; 2) = 2 - * ue = Umoyen pour tous les planchers du batiment - * upb_final = ue - */ - expect(pb.donnee_intermediaire.b).toBe(1); - expect(pb.donnee_intermediaire.upb0).toBe(2); - expect(pb.donnee_intermediaire.upb).toBe(2); - expect(pb.donnee_intermediaire.upb_final).toBe(pb.donnee_entree.ue); + + calc_pb(pb, 'h1a', '1', '0', [pb]); + + // Upb0 = 2 ; R = 1 ; Upb = 1 / (1/2 + 1) + // valeur de référence de régression + expect(pb.donnee_intermediaire.upb).toBeCloseTo(0.6666666666666666, 9); + } + ); +}); + +describe('calc_pb - isolation inconnue / table forfaitaire (methodes 2 et 7)', () => { + test("Upb = min(Upb table, Upb0) et la période d'isolation prime", () => { + const pb = { + donnee_entree: { + enum_methode_saisie_u_id: '2', + methode_saisie_u0: 'type de paroi inconnu (valeur par défaut)', + enum_type_plancher_bas_id: '9', + enum_periode_isolation_id: '6', + type_adjacence: 'extérieur' + } + }; + + calc_pb(pb, 'h1a', '1', '0', [pb]); + + // La période d'isolation renseignée est utilisée comme période de la table + expect(tv).toHaveBeenCalledWith( + 'upb', + expect.objectContaining({ enum_periode_construction_id: '6' }), + pb.donnee_entree + ); + // min(0,3 ; 2) = 0,3 + expect(pb.donnee_intermediaire.upb).toBe(0.3); + }); + + test("sans période d'isolation, la période de construction est utilisée", () => { + const pb = { + donnee_entree: { + enum_methode_saisie_u_id: '7', + methode_saisie_u0: 'type de paroi inconnu (valeur par défaut)', + enum_type_plancher_bas_id: '9', + type_adjacence: 'extérieur' + } + }; + + calc_pb(pb, 'h1a', '5', '0', [pb]); + + expect(tv).toHaveBeenCalledWith( + 'upb', + expect.objectContaining({ enum_periode_construction_id: '5' }), + pb.donnee_entree + ); + }); + + test('Upb introuvable dans la table forfaitaire : erreur émise', () => { + tv.mockImplementation((table) => (table === 'upb0' ? { ...ROW_UPB0 } : null)); + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + const pb = { + donnee_entree: { + enum_methode_saisie_u_id: '2', + methode_saisie_u0: 'type de paroi inconnu (valeur par défaut)', + enum_type_plancher_bas_id: '9', + type_adjacence: 'extérieur' + } + }; + + calc_pb(pb, 'h1a', '1', '0', [pb]); + + expect(error).toHaveBeenCalledWith(expect.stringContaining('upb')); + error.mockRestore(); + }); +}); + +describe('calc_pb - année de construction saisie (methode 8)', () => { + test("période d'isolation renseignée : elle est utilisée", () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const pb = { + donnee_entree: { + enum_methode_saisie_u_id: '8', + methode_saisie_u0: 'type de paroi inconnu (valeur par défaut)', + enum_type_plancher_bas_id: '9', + enum_periode_isolation_id: '6', + type_adjacence: 'extérieur' + } + }; + + calc_pb(pb, 'h1a', '5', '0', [pb]); + + expect(tv).toHaveBeenCalledWith( + 'upb', + expect.objectContaining({ enum_periode_construction_id: '6' }), + pb.donnee_entree + ); + warn.mockRestore(); + }); + + test.each([ + ['1', 'avant 1948'], + ['3', '1948-1974'] + ])( + "construction ancienne (%s = %s) sans année d'isolation : isolation forcée à 1975-1977", + (pcId) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const pb = { + donnee_entree: { + enum_methode_saisie_u_id: '8', + methode_saisie_u0: 'type de paroi inconnu (valeur par défaut)', + enum_type_plancher_bas_id: '9', + type_adjacence: 'extérieur' + } + }; + + calc_pb(pb, 'h1a', pcId, '0', [pb]); + + // getKeyByValue(periode_isolation, '1975-1977') = '2' (différent de pc_id) => avertissement + expect(tv).toHaveBeenCalledWith( + 'upb', + expect.objectContaining({ enum_periode_construction_id: '2' }), + pb.donnee_entree + ); + expect(warn).toHaveBeenCalled(); + warn.mockRestore(); + } + ); + + test('construction ancienne + bug_for_bug : second appel avec la période de construction', () => { + bugForBugCompat = true; + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + // 1er appel (période 1975-1977) renvoie tv_upb_id = 2 ; l'appel avec la période de + // construction ('1') renvoie tv_upb_id = 3 (valeur différente => déclenche le recalcul). + tv.mockImplementation((table, matcher) => { + if (table === 'upb0') return { ...ROW_UPB0 }; + if (table === 'upb') { + return { + upb: '0.3', + effet_joule: '0', + tv_upb_id: matcher.enum_periode_construction_id === '1' ? '3' : '2' + }; + } + return null; + }); + const pb = { + donnee_entree: { + enum_methode_saisie_u_id: '8', + methode_saisie_u0: 'type de paroi inconnu (valeur par défaut)', + enum_type_plancher_bas_id: '9', + type_adjacence: 'extérieur' + } + }; + + calc_pb(pb, 'h1a', '1', '0', [pb]); + + // L'avertissement de bascule d'année d'isolation est émis + expect(warn).toHaveBeenCalledWith(expect.stringContaining('Année d')); + // Le dernier appel forfaitaire utilise la période de construction d'origine ('1') + expect(tv).toHaveBeenLastCalledWith( + 'upb', + expect.objectContaining({ enum_periode_construction_id: '1' }), + pb.donnee_entree + ); + warn.mockRestore(); + error.mockRestore(); + }); + + test("construction récente sans année d'isolation : pas de forçage ni d'avertissement", () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const pb = { + donnee_entree: { + enum_methode_saisie_u_id: '8', + methode_saisie_u0: 'type de paroi inconnu (valeur par défaut)', + enum_type_plancher_bas_id: '9', + type_adjacence: 'extérieur' + } + }; + + calc_pb(pb, 'h1a', '5', '0', [pb]); + + expect(tv).toHaveBeenCalledWith( + 'upb', + expect.objectContaining({ enum_periode_construction_id: '5' }), + pb.donnee_entree + ); + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + }); +}); + +describe('calc_pb - saisie directe de Upb (methodes 9 et 10)', () => { + test.each([['9'], ['10']])('Upb pris depuis upb_saisi (methode %s)', (methode) => { + const pb = { + donnee_entree: { + enum_methode_saisie_u_id: methode, + upb_saisi: 0.22, + type_adjacence: 'extérieur' + } + }; + + calc_pb(pb, 'h1a', '1', '0', [pb]); + + expect(pb.donnee_intermediaire.upb).toBe(0.22); + }); +}); + +describe('calc_pb - méthode de saisie de U inconnue', () => { + test('aucun calcul de Upb et avertissement émis', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const pb = { + donnee_entree: { + enum_methode_saisie_u_id: '99', + type_adjacence: 'extérieur' + } + }; + + calc_pb(pb, 'h1a', '1', '0', [pb]); + + expect(warn).toHaveBeenCalledWith('methode_saisie_u inconnue:', 99); + warn.mockRestore(); + }); +}); + +/** + * Compatibilité "bug for bug" : la méthode de saisie de U est forcée quand une donnée d'entrée + * (résistance, épaisseur ou Upb saisi) est présente mais incohérente avec la méthode déclarée. + */ +describe('calc_pb - compatibilité bug for bug (forçage de la méthode de saisie)', () => { + beforeEach(() => { + bugForBugCompat = true; + }); + + test('résistance connue mais methode != 5/6 : méthode forcée à 5', () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + const pb = { + donnee_entree: { + enum_methode_saisie_u_id: '1', + methode_saisie_u0: 'type de paroi inconnu (valeur par défaut)', + enum_type_plancher_bas_id: '9', + resistance_isolation: '1', + type_adjacence: 'extérieur' + } + }; + + calc_pb(pb, 'h1a', '1', '0', [pb]); + + expect(error).toHaveBeenCalledWith(expect.stringContaining('resistance isolation saisie')); + // Upb calculé comme en methode 5 : 1 / (1/2 + 1) + expect(pb.donnee_intermediaire.upb).toBeCloseTo(0.6666666666666666, 9); + error.mockRestore(); + }); + + test('épaisseur connue mais methode != 3/4 : méthode forcée à 3', () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + const pb = { + donnee_entree: { + enum_methode_saisie_u_id: '1', + methode_saisie_u0: 'type de paroi inconnu (valeur par défaut)', + enum_type_plancher_bas_id: '9', + epaisseur_isolation: '5', + type_adjacence: 'extérieur' + } + }; + + calc_pb(pb, 'h1a', '1', '0', [pb]); + + expect(error).toHaveBeenCalledWith(expect.stringContaining('epaisseur isolation saisie')); + expect(pb.donnee_intermediaire.upb).toBeCloseTo(0.5915492957746479, 9); + error.mockRestore(); + }); + + test('Upb saisi mais methode != 9/10 : méthode forcée à 9', () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + const pb = { + donnee_entree: { + enum_methode_saisie_u_id: '1', + upb_saisi: 0.25, + type_adjacence: 'extérieur' + } + }; + + calc_pb(pb, 'h1a', '1', '0', [pb]); + + expect(error).toHaveBeenCalledWith(expect.stringContaining('saisie direct u')); + expect(pb.donnee_intermediaire.upb).toBe(0.25); + error.mockRestore(); + }); + + test('effet_joule du DPE différent de celui attendu : valeur du DPE conservée dans le matcher', () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + // La relecture via tv_upb_id renvoie effet_joule = '1' alors que l'effet Joule attendu est '0' + tv.mockImplementation((table, matcher) => { + if (table === 'upb0') return { ...ROW_UPB0 }; + if (table === 'upb' && matcher.tv_upb_id) return { effet_joule: '1', tv_upb_id: '2' }; + if (table === 'upb') return { ...ROW_UPB }; + return null; + }); + const pb = { + donnee_entree: { + enum_methode_saisie_u_id: '2', + methode_saisie_u0: 'type de paroi inconnu (valeur par défaut)', + enum_type_plancher_bas_id: '9', + tv_upb_id: 2, + type_adjacence: 'extérieur', + description: 'PB test' + } + }; + + calc_pb(pb, 'h1a', '1', '0', [pb]); + + expect(error).toHaveBeenCalledWith(expect.stringContaining('effet_joule')); + // Le matcher forfaitaire final utilise l'effet Joule '1' issu du DPE + expect(tv).toHaveBeenLastCalledWith( + 'upb', + expect.objectContaining({ effet_joule: '1' }), + pb.donnee_entree + ); + error.mockRestore(); + }); + + test('données cohérentes avec la méthode : aucun forçage', () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + const pb = { + donnee_entree: { + enum_methode_saisie_u_id: '5', + methode_saisie_u0: 'type de paroi inconnu (valeur par défaut)', + enum_type_plancher_bas_id: '9', + resistance_isolation: '1', + epaisseur_isolation: '5', + upb_saisi: 0.25, + type_adjacence: 'extérieur' + } + }; + + calc_pb(pb, 'h1a', '1', '0', [pb]); + + // methode 5 cohérente avec resistance_isolation => pas d'erreur de forçage de résistance, + // mais épaisseur et upb_saisi présents restent incohérents (couverture des branches vraies) + expect(error).toHaveBeenCalled(); + error.mockRestore(); + }); +}); + +/** + * Calcul de la déperdition Ue vers un plancher bas sur terre-plein / vide sanitaire / sous-sol. + * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §3.2.2 (Ue) + */ +describe('calc_pb - calcul de Ue (tv_ue)', () => { + /** Renvoie une ue interpolée selon la valeur de upb du matcher. */ + const mockTvUe = (ueForUpb) => { + tv.mockImplementation((table, matcher) => { + if (table === 'upb0') return { ...ROW_UPB0, upb0: '0.4' }; + if (table === 'ue') return { ue: String(ueForUpb[matcher.upb]) }; + return null; }); + }; + + test('terre-plein, bâtiment avant 2001 (pc < 7) : interpolation linéaire de Ue', () => { + getRange.mockReturnValue([0.3, 0.5]); + mockTvUe({ 0.3: 0.2, 0.5: 0.4 }); + + // Deux planchers de même adjacence + un plancher d'adjacence différente (ignoré) + const autre = { + donnee_entree: { enum_type_adjacence_id: '8', surface_paroi_opaque: 99, perimetre_ue: 99 } + }; + const pb1 = { + donnee_entree: { enum_type_adjacence_id: '5', surface_ue: 20, perimetre_ue: 12 } + }; + const pb = { + donnee_entree: { + enum_methode_saisie_u_id: '1', + methode_saisie_u0: 'type de paroi inconnu (valeur par défaut)', + enum_type_plancher_bas_id: '9', + type_adjacence: 'terre-plein', + enum_type_adjacence_id: '5', + surface_paroi_opaque: 4 // pas de surface_ue => fallback + } + }; + + calc_pb(pb, 'h1a', '1', '0', [pb1, pb, autre]); + + // surfaceUe = 20 (pb1.surface_ue) + 4 (pb.surface_paroi_opaque) = 24 ; perimetreUe = 12 + 0 = 12 + // 2S/P = round(48/12) = 4 (valeur présente dans values_2s_p) + expect(tv).toHaveBeenCalledWith('ue', { + type_adjacence_plancher: 'terre plein bâtiment construit avant 2001', + '2s_p': '^4$', + upb: '0.3' + }); + // di.upb = 0,4 ; interpolation : 0,2 + (0,4-0,3)*(0,4-0,2)/(0,5-0,3) = 0,3 + // valeur de référence de régression + expect(pb.donnee_entree.ue).toBeCloseTo(0.3, 9); + expect(pb.donnee_intermediaire.upb_final).toBeCloseTo(0.3, 9); + }); + + test('terre-plein, bâtiment à partir de 2001 (pc >= 7) : catégorie adaptée', () => { + getRange.mockReturnValue([0.31, 0.31]); + mockTvUe({ 0.31: 0.25 }); + const pb = { + donnee_entree: { + enum_methode_saisie_u_id: '1', + methode_saisie_u0: 'type de paroi inconnu (valeur par défaut)', + enum_type_plancher_bas_id: '9', + type_adjacence: 'terre-plein', + enum_type_adjacence_id: '5', + surface_ue: 10, + perimetre_ue: 10 + } + }; + + calc_pb(pb, 'h1a', '8', '0', [pb]); + + expect(tv).toHaveBeenCalledWith( + 'ue', + expect.objectContaining({ + type_adjacence_plancher: 'terre plein bâtiment construit à partir de 2001' + }) + ); + // delta_upb = 0 => ue = ue de la borne inférieure = 0,25 + expect(pb.donnee_entree.ue).toBe(0.25); + }); + + test('vide sanitaire / sous-sol non chauffé : catégorie plancher sur vide sanitaire', () => { + getRange.mockReturnValue([0.31, 0.31]); + mockTvUe({ 0.31: 0.18 }); + const pb = { + donnee_entree: { + enum_methode_saisie_u_id: '1', + methode_saisie_u0: 'type de paroi inconnu (valeur par défaut)', + enum_type_plancher_bas_id: '9', + type_adjacence: 'vide sanitaire', + enum_type_adjacence_id: '7', + surface_ue: 10, + perimetre_ue: 10 + } + }; + + calc_pb(pb, 'h1a', '1', '0', [pb]); + + expect(tv).toHaveBeenCalledWith( + 'ue', + expect.objectContaining({ + type_adjacence_plancher: 'plancher sur vide sanitaire ou sous-sol non chauffé' + }) + ); + expect(pb.donnee_intermediaire.upb_final).toBe(0.18); + }); + + test('adjacence sous-sol non chauffé : upb_final = Ue', () => { + getRange.mockReturnValue([0.31, 0.31]); + mockTvUe({ 0.31: 0.19 }); + const pb = { + donnee_entree: { + enum_methode_saisie_u_id: '1', + methode_saisie_u0: 'type de paroi inconnu (valeur par défaut)', + enum_type_plancher_bas_id: '9', + type_adjacence: 'sous-sol non chauffé', + enum_type_adjacence_id: '6', + surface_ue: 10, + perimetre_ue: 10 + } + }; + + calc_pb(pb, 'h1a', '1', '0', [pb]); + + expect(pb.donnee_intermediaire.upb_final).toBe(0.19); }); }); 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 00000000..71be3254 --- /dev/null +++ b/src/3.2.3_plancher_haut.spec.js @@ -0,0 +1,452 @@ +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` : exposé via un getter afin de pouvoir le basculer par test. + */ +let bugForBugCompat = false; + +vi.mock('./enums.js', () => ({ + default: { + periode_construction: { 1: 'avant 1948', 3: '1948-1974', 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), + get bug_for_bug_compat() { + return bugForBugCompat; + } +})); + +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(() => { + bugForBugCompat = false; + 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); + }); + + test('Uph0 initialisé depuis une donnée intermédiaire préexistante', () => { + const ph = { + donnee_entree: { + methode_saisie_u: 'non isolé', + methode_saisie_u0: 'u0 non saisi car le u est saisi connu et justifié.' + }, + donnee_intermediaire: { uph0: 1.5 } + }; + + calc_ph(ph, 'h1a', '1', '0'); + + // Aucune méthode ne recalcule Uph0 ici : la valeur préexistante est conservée + expect(ph.donnee_intermediaire.uph0).toBe(1.5); + expect(ph.donnee_intermediaire.uph).toBe(1.5); + }); + + test('Uph0 introuvable dans la table : erreur émise et uph0 non défini', () => { + tv.mockReturnValue(null); + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + const ph = { + donnee_entree: { + methode_saisie_u: 'non isolé', + methode_saisie_u0: + 'déterminé selon le matériau et épaisseur à partir de la table de valeur forfaitaire', + type_plancher_haut: 'terrasse' + } + }; + + calc_ph(ph, 'h1a', '1', '0'); + + expect(ph.donnee_intermediaire.uph0).toBeUndefined(); + expect(error).toHaveBeenCalledWith(expect.stringContaining('uph0')); + error.mockRestore(); + }); +}); + +describe('calc_ph - calcul de Uph0 (calc_uph0)', () => { + test('saisie directe de Uph0 : valeur reprise depuis uph0_saisi sans accès table', () => { + const ph = { + donnee_entree: { + methode_saisie_u: 'non isolé', + methode_saisie_u0: + 'saisie direct u0 justifiée à partir des documents justificatifs autorisés', + uph0_saisi: 0.9 + } + }; + + calc_ph(ph, 'h1a', '1', '0'); + + expect(tv).not.toHaveBeenCalled(); + expect(ph.donnee_intermediaire.uph0).toBe(0.9); + expect(ph.donnee_intermediaire.uph).toBe(0.9); + }); + + test('Uph0 non saisi car U est saisi : ni accès table, ni valeur Uph0', () => { + const ph = { + donnee_entree: { + methode_saisie_u: 'non isolé', + methode_saisie_u0: 'u0 non saisi car le u est saisi connu et justifié.' + } + }; + + calc_ph(ph, 'h1a', '1', '0'); + + expect(tv).not.toHaveBeenCalled(); + expect(ph.donnee_intermediaire.uph0).toBeUndefined(); + expect(ph.donnee_intermediaire.uph).toBeUndefined(); + }); + + test('methode_saisie_u0 inconnue : avertissement émis et Uph0 non calculé', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const ph = { + donnee_entree: { + methode_saisie_u: 'non isolé', + methode_saisie_u0: 'valeur inexistante' + } + }; + + calc_ph(ph, 'h1a', '1', '0'); + + expect(warn).toHaveBeenCalledWith('methode_saisie_u0 inconnue:', 'valeur inexistante'); + expect(ph.donnee_intermediaire.uph0).toBeUndefined(); + warn.mockRestore(); + }); +}); + +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); + }); + + test('Uph introuvable dans la table : erreur émise et uph non défini par tv_uph', () => { + tv.mockImplementation((table) => (table === 'uph0' ? { ...ROW_UPH0 } : null)); + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + 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'); + + expect(error).toHaveBeenCalledWith(expect.stringContaining('uph')); + // di.uph vaut undefined avant le Math.min => min(undefined, 2) = NaN + expect(ph.donnee_intermediaire.uph).toBeNaN(); + error.mockRestore(); + }); + + /** + * 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' }) + ); + }); +}); + +/** + * Compatibilité "bug for bug" : lorsque le DPE fournit un `tv_uph_id`, on relit la ligne + * de la table `uph` pour, le cas échéant, corriger le type de toiture et l'effet Joule. + */ +describe('calc_ph - compatibilité bug for bug (récupération depuis tv_uph_id)', () => { + beforeEach(() => { + bugForBugCompat = true; + }); + + test('type_toiture "combles" du DPE : la valeur du DPE prime sur celle calculée', () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + // La ligne relue via tv_uph_id impose type_toiture = 'combles' et effet_joule = '0' + tv.mockImplementation((table, matcher) => { + if (table === 'uph0') return { ...ROW_UPH0 }; + if (table === 'uph' && matcher.tv_uph_id) { + return { type_toiture: 'combles', effet_joule: '0' }; + } + if (table === 'uph') { + // On vérifie ici que le matcher final utilise bien type_toiture = 'combles' + return matcher.type_toiture === 'combles' ? { ...ROW_UPH } : null; + } + return null; + }); + + 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', // calcul => 'terrasse' + type_plancher_haut: 'terrasse', + tv_uph_id: 7, + description: 'PH test' + } + }; + + calc_ph(ph, 'h1a', '1', '0'); + + expect(error).toHaveBeenCalled(); + expect(tv).toHaveBeenCalledWith('uph', expect.objectContaining({ type_toiture: 'combles' })); + expect(ph.donnee_intermediaire.uph).toBe(0.3); + error.mockRestore(); + }); + + test('effet_joule du DPE différent : la valeur du DPE est conservée dans le matcher', () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + tv.mockImplementation((table, matcher) => { + if (table === 'uph0') return { ...ROW_UPH0 }; + if (table === 'uph' && matcher.tv_uph_id) { + // type_toiture 'terrasse' => pas de correction du type ; effet_joule différent => correction + return { type_toiture: 'terrasse', effet_joule: '1' }; + } + if (table === 'uph') return { ...ROW_UPH }; + return null; + }); + + 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', + tv_uph_id: 7, + description: 'PH test' + } + }; + + calc_ph(ph, 'h1a', '1', '0'); + + expect(error).toHaveBeenCalled(); + expect(tv).toHaveBeenCalledWith('uph', expect.objectContaining({ effet_joule: '1' })); + error.mockRestore(); + }); +}); + +describe('calc_ph - année de construction saisie (table forfaitaire)', () => { + test("période d'isolation renseignée : elle est utilisée comme période de la table", () => { + const ph = { + donnee_entree: { + methode_saisie_u: 'année de construction saisie (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', '5', '0'); + + expect(tv).toHaveBeenCalledWith( + 'uph', + expect.objectContaining({ enum_periode_construction_id: '6' }) + ); + expect(ph.donnee_intermediaire.uph).toBe(0.3); + }); + + test.each([ + ['1', 'avant 1948'], + ['3', '1948-1974'] + ])( + "période de construction ancienne (%s = %s) sans année d'isolation : isolation forcée à 1975-1977", + (pcId) => { + const ph = { + donnee_entree: { + methode_saisie_u: 'année de construction saisie (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', pcId, '0'); + + // getKeyByValue(periode_isolation, '1975-1977') = '2' + expect(tv).toHaveBeenCalledWith( + 'uph', + expect.objectContaining({ enum_periode_construction_id: '2' }) + ); + } + ); + + test("période de construction récente sans année d'isolation : période de construction conservée", () => { + const ph = { + donnee_entree: { + methode_saisie_u: 'année de construction saisie (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', '5', '0'); + + // pc = '1989-2000' => pas de forçage, la période de construction '5' est conservée + expect(tv).toHaveBeenCalledWith( + 'uph', + expect.objectContaining({ enum_periode_construction_id: '5' }) + ); + }); +}); + +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(); + }); +}); 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 00000000..c918ad48 --- /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(); + }); +}); diff --git a/src/3.3_baie_vitree.spec.js b/src/3.3_baie_vitree.spec.js index 479f63dd..c155b7cc 100644 --- a/src/3.3_baie_vitree.spec.js +++ b/src/3.3_baie_vitree.spec.js @@ -1,334 +1,488 @@ -import calc_bv from './3.3_baie_vitree.js'; -import { PRECISION } from '../test/constant.js'; -import { describe, expect, test } from 'vitest'; - -describe('Recherche de bugs dans le calcul de déperdition des baies vitrées', () => { - test('calcul de déperdition pour les murs de 2187E0982013C baie 1', () => { - const zc = 3; - const bv = { - donnee_entree: { - description: - 'Fenêtre 1 Nord - Fenêtres battantes bois, orientées Nord, simple vitrage avec volets battants bois', - enum_type_adjacence_id: '1', - surface_totale_baie: 3.2, - nb_baie: 1, - tv_ug_id: 1, - enum_type_vitrage_id: '1', - enum_inclinaison_vitrage_id: '3', - enum_methode_saisie_perf_vitrage_id: '1', - tv_uw_id: 561, - enum_type_materiaux_menuiserie_id: '3', - enum_type_baie_id: '4', - double_fenetre: 0, - uw_1: 5.4, - sw_1: 0.52, - tv_deltar_id: 6, - tv_ujn_id: 224, - enum_type_fermeture_id: '7', - presence_retour_isolation: 0, - largeur_dormant: 5, - tv_sw_id: 89, - enum_type_pose_id: '3', - enum_orientation_id: '2', - tv_coef_masque_proche_id: 19, - masque_lointain_non_homogene_collection: '', - reference: 'baie_vitree_0', - tv_coef_reduction_deperdition_id: 1 - }, - donnee_intermediaire: { - b: 1, - sw: 0.52, - ug: 5.8, - uw: 5.4, - ujn: 3.8, - u_menuiserie: 3.8, - fe2: 1, - fe1: 1 - } - }; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +/** + * Dépendances mockées pour isoler `calc_bv` (calcul des déperditions des baies vitrées) : + * - `b` (3.1_b.js) : renseigne simplement `di.b` (coefficient de réduction) ; + * - `utils` : `tv` (table de valeurs, dispatchée par nom de table), `requestInput` / + * `requestInputID` (passe-plats vers les données d'entrée), `getRange` (encadrement contrôlé), + * `bug_for_bug_compat` (accesseur adossé à un état hoisté, activable par test) ; + * - `tv.js` : table `uw` réduite aux lignes utiles à l'interpolation. + */ +const state = vi.hoisted(() => ({ bug: false })); + +vi.mock('./3.1_b.js', () => ({ + default: vi.fn((di) => { + di.b = 1; + }) +})); + +vi.mock('./utils.js', () => ({ + tv: vi.fn(), + requestInput: (de, du, field) => de[field], + requestInputID: (de, du, field) => de[`enum_${field}_id`], + getRange: vi.fn(), + get bug_for_bug_compat() { + return state.bug; + } +})); + +vi.mock('./tv.js', () => ({ + default: { + uw: [ + { enum_type_baie_id: '4', enum_type_materiaux_menuiserie_id: '3', ug: '2.8' }, + { enum_type_baie_id: '4', enum_type_materiaux_menuiserie_id: '3', ug: '4' } + ] + } +})); + +const { default: calc_bv } = await import('./3.3_baie_vitree.js'); +const { tv, getRange } = await import('./utils.js'); + +/** + * Lignes retournées par défaut pour chaque table de valeurs. La table `uw` est dispatchée + * selon la borne d'encadrement (`^2.8$` ou `^4$`) afin de piloter l'interpolation. + */ +function defaultRows() { + return { + ug: { ug: '2.8', tv_ug_id: '5' }, + sw: { sw: '0.5', tv_sw_id: '1' }, + uw: (matcher) => (matcher.ug === '^4$' ? { uw: '1.5' } : { uw: '3', tv_uw_id: '10' }), + deltar: { deltar: '0.1', tv_deltar_id: '6' }, + ujn: { ujn: '2.4', tv_ujn_id: '201' }, + coef_masque_proche: { fe1: '0.8', tv_coef_masque_proche_id: '19' }, + coef_masque_lointain_homogene: { fe2: '0.9', tv_coef_masque_lointain_homogene_id: '3' }, + coef_masque_lointain_non_homoge: { omb: '20' } + }; +} + +let rows; +let errorSpy; +let logSpy; + +beforeEach(() => { + vi.mocked(tv).mockReset(); + vi.mocked(getRange).mockReset(); + state.bug = false; + rows = defaultRows(); + + vi.mocked(tv).mockImplementation((table, matcher) => { + const entry = rows[table]; + return typeof entry === 'function' ? entry(matcher) : entry; + }); + // Encadrement par défaut : bornes identiques => interpolation dégénérée (delta_ug = 0) + vi.mocked(getRange).mockReturnValue([2.8, 2.8]); + + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); +}); + +afterEach(() => { + errorSpy.mockRestore(); + logSpy.mockRestore(); +}); + +/** Baie vitrée double vitrage, avec fermeture, sans masque ni double-fenêtre. */ +function baseBv(overrides = {}) { + return { + donnee_entree: { + description: 'Fenêtre test', + enum_type_vitrage_id: '2', + enum_type_gaz_lame_id: '1', + enum_inclinaison_vitrage_id: '3', + vitrage_vir: 0, + epaisseur_lame: 12, + enum_type_baie_id: '4', + enum_type_materiaux_menuiserie_id: '3', + enum_type_pose_id: '3', + enum_type_fermeture_id: '7', + type_fermeture: 'volets battants bois', + surface_totale_baie: 2.6, + ...overrides + } + }; +} + +/** + * 3.3 Coefficients de transmission thermique et facteur solaire des baies vitrées. + * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §3.3 + */ +describe('calc_bv - parcours nominal (double vitrage avec fermeture)', () => { + test('renseigne b, sw, ug, uw, ujn et u_menuiserie', () => { + const bv = baseBv(); + calc_bv(bv, 1); - calc_bv(bv, zc); + const di = bv.donnee_intermediaire; + expect(di.b).toBe(1); + expect(di.sw).toBe(0.5); + expect(di.ug).toBe(2.8); + expect(di.uw).toBe(3); // getRange [2.8, 2.8] => delta_ug = 0 => uw = uw(^2.8$) + expect(di.ujn).toBe(2.4); + expect(di.u_menuiserie).toBe(2.4); // fermeture présente => u_menuiserie = ujn + expect(di.fe1).toBe(1); // pas de masque proche + expect(di.fe2).toBe(1); // pas de masque lointain + expect(di.deltar).toBeUndefined(); // supprimé en fin de calcul + expect(bv.donnee_entree.tv_ug_id).toBe(5); + }); + + test('matcher ug enrichi pour un double vitrage (gaz, inclinaison, vir, épaisseur)', () => { + const bv = baseBv(); + calc_bv(bv, 1); - expect(bv.donnee_intermediaire.b).toBe(1); + expect(tv).toHaveBeenCalledWith('ug', { + enum_type_vitrage_id: '2', + enum_type_gaz_lame_id: '1', + enum_inclinaison_vitrage_id: '3', + vitrage_vir: 0, + epaisseur_lame: 12 + }); + }); +}); + +describe('tv_ug - coefficient de transmission du vitrage', () => { + test('simple vitrage (type 1) : matcher réduit au seul type de vitrage', () => { + rows.ug = { ug: '5.8', tv_ug_id: '1' }; + const bv = baseBv({ enum_type_vitrage_id: '1' }); + calc_bv(bv, 1); + + expect(tv).toHaveBeenCalledWith('ug', { enum_type_vitrage_id: '1' }); expect(bv.donnee_intermediaire.ug).toBe(5.8); - expect(bv.donnee_intermediaire.uw).toBe(5.4); - expect(bv.donnee_intermediaire.ujn).toBe(3.8); - expect(bv.donnee_intermediaire.u_menuiserie).toBe(3.8); - expect(bv.donnee_intermediaire.sw).toBe(0.52); - expect(bv.donnee_intermediaire.fe1).toBe(1); - expect(bv.donnee_intermediaire.fe2).toBe(1); }); - test('calcul de déperdition pour les murs de 2187E0982013C baie 2', () => { - const zc = 3; - const bv = { - donnee_entree: { - description: - "Fenêtre 2 Est - Fenêtres battantes bois, orientées Est, double vitrage avec lame d'air 12 mm et volets battants bois", - enum_type_adjacence_id: '1', - surface_totale_baie: 2.6, - nb_baie: 2, - tv_ug_id: 5, - enum_type_vitrage_id: '2', - enum_inclinaison_vitrage_id: '3', - enum_type_gaz_lame_id: '1', - epaisseur_lame: 12, - vitrage_vir: 0, - enum_methode_saisie_perf_vitrage_id: '1', - tv_uw_id: 548, - enum_type_materiaux_menuiserie_id: '3', - enum_type_baie_id: '4', - double_fenetre: 0, - uw_1: 3, - sw_1: 0.47000000000000003, - tv_deltar_id: 6, - tv_ujn_id: 201, - enum_type_fermeture_id: '7', - presence_retour_isolation: 0, - largeur_dormant: 5, - tv_sw_id: 90, - enum_type_pose_id: '3', - enum_orientation_id: '3', - tv_coef_masque_proche_id: 19, - masque_lointain_non_homogene_collection: '' - }, - donnee_intermediaire: { - b: 1, - ug: 2.8, - uw: 3, - ujn: 2.4, - u_menuiserie: 2.4, - sw: 0.47000000000000003, - fe1: 1, - fe2: 1 - } - }; + test('type de vitrage absent : matcher réduit (aucun enrichissement)', () => { + rows.ug = { ug: '5.8', tv_ug_id: '1' }; + const bv = baseBv({ enum_type_vitrage_id: undefined }); + calc_bv(bv, 1); - calc_bv(bv, zc); + expect(tv).toHaveBeenCalledWith('ug', { enum_type_vitrage_id: undefined }); + }); - expect(bv.donnee_intermediaire.b).toBe(1); - expect(bv.donnee_intermediaire.ug).toBe(2.8); - expect(bv.donnee_intermediaire.uw).toBe(3); - expect(bv.donnee_intermediaire.ujn).toBe(2.4); - expect(bv.donnee_intermediaire.u_menuiserie).toBe(2.4); - expect(bv.donnee_intermediaire.sw).toBeCloseTo(0.47000000000000003, PRECISION); - expect(bv.donnee_intermediaire.fe1).toBe(1); - expect(bv.donnee_intermediaire.fe2).toBe(1); + test('aucune ligne ug trouvée : erreur émise', () => { + rows.ug = null; + const bv = baseBv(); + calc_bv(bv, 1); + + expect(errorSpy).toHaveBeenCalledWith('!! pas de valeur forfaitaire trouvée pour ug !!'); + expect(bv.donnee_intermediaire.ug).toBeUndefined(); }); +}); - test('calcul de déperdition pour les murs de 2187E0982013C baie 3', () => { - const zc = 3; - const bv = { - donnee_entree: { - description: - "Fenêtre 3 Est - Fenêtres battantes bois, orientées Est, double vitrage avec lame d'air 12 mm et volets battants bois", - enum_type_adjacence_id: '1', - surface_totale_baie: 4.5, - nb_baie: 3, - tv_ug_id: 5, - enum_type_vitrage_id: '2', - enum_inclinaison_vitrage_id: '3', - enum_type_gaz_lame_id: '1', - epaisseur_lame: 12, - vitrage_vir: 0, - enum_methode_saisie_perf_vitrage_id: '1', - tv_uw_id: 548, - enum_type_materiaux_menuiserie_id: '3', - enum_type_baie_id: '4', - double_fenetre: 0, - uw_1: 3, - sw_1: 0.47000000000000003, - tv_deltar_id: 6, - tv_ujn_id: 201, - enum_type_fermeture_id: '7', - presence_retour_isolation: 0, - largeur_dormant: 5, - tv_sw_id: 90, - enum_type_pose_id: '3', - enum_orientation_id: '3', - tv_coef_masque_proche_id: 19, - masque_lointain_non_homogene_collection: '' - }, - donnee_intermediaire: { - b: 1, - ug: 2.8, - uw: 3, - ujn: 2.4, - u_menuiserie: 2.4, - sw: 0.47000000000000003, - fe1: 1, - fe2: 1 - } - }; +describe('tv_sw - facteur solaire', () => { + test('sw saisi : valeur reprise directement sans accès table', () => { + const bv = baseBv({ sw_saisi: 0.42 }); + calc_bv(bv, 1); - calc_bv(bv, zc); + expect(bv.donnee_intermediaire.sw).toBe(0.42); + expect(tv).not.toHaveBeenCalledWith('sw', expect.anything()); + }); - expect(bv.donnee_intermediaire.b).toBe(1); - expect(bv.donnee_intermediaire.ug).toBe(2.8); - expect(bv.donnee_intermediaire.uw).toBe(3); - expect(bv.donnee_intermediaire.ujn).toBe(2.4); - expect(bv.donnee_intermediaire.u_menuiserie).toBe(2.4); - expect(bv.donnee_intermediaire.sw).toBeCloseTo(0.47000000000000003, PRECISION); - expect(bv.donnee_intermediaire.fe1).toBe(1); - expect(bv.donnee_intermediaire.fe2).toBe(1); + test('menuiserie brique de verre (matériau 1) : ni vir ni pose dans le matcher', () => { + const bv = baseBv({ enum_type_materiaux_menuiserie_id: '1' }); + calc_bv(bv, 1); + + expect(tv).toHaveBeenCalledWith('sw', { + enum_type_vitrage_id: '2', + enum_type_baie_id: '4', + enum_type_materiaux_menuiserie_id: '1' + }); + }); + + test('menuiserie classique : matcher enrichi (vir + pose)', () => { + const bv = baseBv(); + calc_bv(bv, 1); + + expect(tv).toHaveBeenCalledWith('sw', { + enum_type_vitrage_id: '2', + enum_type_baie_id: '4', + enum_type_materiaux_menuiserie_id: '3', + vitrage_vir: 0, + enum_type_pose_id: '3' + }); + }); + + test('aucune ligne sw trouvée : erreur émise', () => { + rows.sw = null; + const bv = baseBv(); + calc_bv(bv, 1); + + expect(errorSpy).toHaveBeenCalledWith('!! pas de valeur forfaitaire trouvée pour sw !!'); }); +}); + +describe('tv_uw - coefficient de transmission de la fenêtre', () => { + test('uw saisi : valeur reprise directement', () => { + const bv = baseBv({ uw_saisi: 2.1 }); + calc_bv(bv, 1); + expect(bv.donnee_intermediaire.uw).toBe(2.1); + }); + + test('paroi en brique de verre (type baie 1) : uw lu directement dans la table', () => { + rows.uw = { uw: '2', tv_uw_id: '10' }; + const bv = baseBv({ enum_type_baie_id: '1' }); + calc_bv(bv, 1); + + expect(tv).toHaveBeenCalledWith('uw', { enum_type_baie_id: '1' }); + expect(bv.donnee_intermediaire.uw).toBe(2); + }); + + test('brique de verre sans ligne uw : uw indéfini et erreur émise', () => { + rows.uw = null; + const bv = baseBv({ enum_type_baie_id: '1' }); + calc_bv(bv, 1); + + expect(bv.donnee_intermediaire.uw).toBeUndefined(); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Pas de valeur forfaitaire uw')); + }); + + test('interpolation entre deux Ug tabulés (delta_ug ≠ 0)', () => { + rows.ug = { ug: '3', tv_ug_id: '5' }; + getRange.mockReturnValue([2.8, 4]); + const bv = baseBv(); + calc_bv(bv, 1); - test('calcul de déperdition pour les murs de 2187E0982013C baie 4', () => { - const zc = 3; - const bv = { + // uw = 3 + (1,5 - 3) * (3 - 2,8) / (4 - 2,8) = 2,75 (référence de régression) + expect(bv.donnee_intermediaire.uw).toBeCloseTo(2.75, 9); + }); + + test('interpolation sans ligne uw exploitable : uw indéfini et erreur émise', () => { + getRange.mockReturnValue([2.8, 4]); + rows.uw = null; + const bv = baseBv(); + calc_bv(bv, 1); + + expect(bv.donnee_intermediaire.uw).toBeUndefined(); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Pas de valeur forfaitaire uw')); + }); +}); + +describe('calc_bv - traitement de la fermeture', () => { + test('ujn saisi : valeur reprise, u_menuiserie = ujn', () => { + const bv = baseBv({ ujn_saisi: 1.9 }); + calc_bv(bv, 1); + + expect(bv.donnee_intermediaire.ujn).toBe(1.9); + expect(bv.donnee_intermediaire.u_menuiserie).toBe(1.9); + }); + + test('absence de fermeture : u_menuiserie = uw (pas de calcul de ujn)', () => { + const bv = baseBv({ type_fermeture: 'abscence de fermeture pour la baie vitrée' }); + calc_bv(bv, 1); + + expect(bv.donnee_intermediaire.u_menuiserie).toBe(3); // = uw + expect(bv.donnee_intermediaire.ujn).toBeUndefined(); + }); + + test('aucune ligne deltar trouvée : erreur émise', () => { + rows.deltar = null; + const bv = baseBv(); + calc_bv(bv, 1); + + expect(errorSpy).toHaveBeenCalledWith('!! pas de valeur forfaitaire trouvée pour deltar !!'); + }); + + test('aucune ligne ujn trouvée : erreur émise', () => { + rows.ujn = null; + const bv = baseBv(); + calc_bv(bv, 1); + + expect(errorSpy).toHaveBeenCalledWith('!! pas de valeur forfaitaire trouvée pour ujn !!'); + }); +}); + +/** + * 3.3.2 / 6.2.1 Traitement des doubles fenêtres : sw et uw équivalents. + * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §3.3.2 + */ +describe('calc_bv - double fenêtre', () => { + /** Baie secondaire (deuxième fenêtre) mesurable pour sw / uw équivalents. */ + function secondFenetre(overrides = {}) { + return { donnee_entree: { - description: - "Fenêtre 4 Ouest - Fenêtres battantes bois, orientées Ouest, double vitrage avec lame d'air 12 mm et volets battants bois", - enum_type_adjacence_id: '1', - surface_totale_baie: 3.84, - nb_baie: 4, - tv_ug_id: 5, + description: 'Seconde fenêtre', enum_type_vitrage_id: '2', - enum_inclinaison_vitrage_id: '3', enum_type_gaz_lame_id: '1', - epaisseur_lame: 12, + enum_inclinaison_vitrage_id: '3', vitrage_vir: 0, - enum_methode_saisie_perf_vitrage_id: '1', - tv_uw_id: 548, - enum_type_materiaux_menuiserie_id: '3', + epaisseur_lame: 12, enum_type_baie_id: '4', - double_fenetre: 0, - uw_1: 3, - sw_1: 0.47000000000000003, - tv_deltar_id: 6, - tv_ujn_id: 201, - enum_type_fermeture_id: '7', - presence_retour_isolation: 0, - largeur_dormant: 5, - tv_sw_id: 90, + enum_type_materiaux_menuiserie_id: '3', enum_type_pose_id: '3', - enum_orientation_id: '4', - tv_coef_masque_proche_id: 19, - masque_lointain_non_homogene_collection: '' + ...overrides }, - donnee_intermediaire: { - b: 1, - ug: 2.8, - uw: 3, - ujn: 2.4, - u_menuiserie: 2.4, - sw: 0.47000000000000003, - fe1: 1, - fe2: 1 - } + donnee_intermediaire: {} }; + } + + test('sw et uw équivalents calculés à partir des deux fenêtres', () => { + const bv = baseBv({ double_fenetre: 1 }); + bv.baie_vitree_double_fenetre = secondFenetre(); + calc_bv(bv, 1); + + // sw équivalent = sw1 * sw2 = 0,5 * 0,5 = 0,25 + expect(bv.donnee_intermediaire.sw).toBeCloseTo(0.25, 9); + // uw équivalent = 1 / (1/3 + 1/3 + 0,07) (référence de régression) + expect(bv.donnee_intermediaire.uw).toBeCloseTo(1.3574660633484164, 9); + }); + + test('sw et uw saisis sur la seconde fenêtre : valeurs reprises directement', () => { + const bv = baseBv({ double_fenetre: 1 }); + bv.baie_vitree_double_fenetre = secondFenetre({ sw_saisi: 0.8, uw_saisi: 2 }); + calc_bv(bv, 1); + + // sw équivalent = 0,5 * 0,8 = 0,4 + expect(bv.donnee_intermediaire.sw).toBeCloseTo(0.4, 9); + // uw équivalent = 1 / (1/3 + 1/2 + 0,07) + expect(bv.donnee_intermediaire.uw).toBeCloseTo(1 / (1 / 3 + 1 / 2 + 0.07), 9); + }); + + test('sw nul sur la seconde fenêtre : facteur multiplicatif ramené à 1', () => { + // La seconde fenêtre (matériau brique de verre) renvoie un sw nul => on retombe sur 1 + rows.sw = (matcher) => + matcher.enum_type_materiaux_menuiserie_id === '1' + ? { sw: '0' } + : { sw: '0.5', tv_sw_id: '1' }; + const bv = baseBv({ double_fenetre: 1 }); + bv.baie_vitree_double_fenetre = secondFenetre({ enum_type_materiaux_menuiserie_id: '1' }); + calc_bv(bv, 1); - calc_bv(bv, zc); + // sw équivalent = sw1 * 1 = 0,5 + expect(bv.donnee_intermediaire.sw).toBeCloseTo(0.5, 9); + }); - expect(bv.donnee_intermediaire.b).toBe(1); - expect(bv.donnee_intermediaire.ug).toBe(2.8); + test('double_fenetre = 1 mais aucune baie secondaire : traitement ignoré', () => { + const bv = baseBv({ double_fenetre: 1 }); + calc_bv(bv, 1); + + // sw et uw restent ceux de la fenêtre principale + expect(bv.donnee_intermediaire.sw).toBe(0.5); expect(bv.donnee_intermediaire.uw).toBe(3); - expect(bv.donnee_intermediaire.ujn).toBe(2.4); - expect(bv.donnee_intermediaire.u_menuiserie).toBe(2.4); - expect(bv.donnee_intermediaire.sw).toBeCloseTo(0.47000000000000003, PRECISION); - expect(bv.donnee_intermediaire.fe1).toBe(1); - expect(bv.donnee_intermediaire.fe2).toBe(1); }); - test('calcul de déperdition pour les murs de 2187E0982013C baie 5', () => { - const zc = 3; - const bv = { - donnee_entree: { - description: - "Fenêtre 5 Ouest - Fenêtres battantes bois, orientées Ouest, double vitrage avec lame d'air 12 mm", - enum_type_adjacence_id: '1', - surface_totale_baie: 0.4, - nb_baie: 1, - tv_ug_id: 5, - enum_type_vitrage_id: '2', - enum_inclinaison_vitrage_id: '3', - enum_type_gaz_lame_id: '1', - epaisseur_lame: 12, - vitrage_vir: 0, - enum_methode_saisie_perf_vitrage_id: '1', - tv_uw_id: 548, - enum_type_materiaux_menuiserie_id: '3', - enum_type_baie_id: '4', - double_fenetre: 0, - uw_1: 3, - sw_1: 0.47000000000000003, - enum_type_fermeture_id: '1', - presence_retour_isolation: 0, - largeur_dormant: 5, - tv_sw_id: 90, - enum_type_pose_id: '3', - enum_orientation_id: '4', - tv_coef_masque_proche_id: 19, - masque_lointain_non_homogene_collection: '' - }, - donnee_intermediaire: { - b: 1, - ug: 2.8, - uw: 3, - u_menuiserie: 3, - sw: 0.47000000000000003, - fe1: 1, - fe2: 1 + /** + * Compat bug : sw et uw sont saisis sur la fenêtre principale mais égaux aux valeurs + * équivalentes calculées ; ce sont ces dernières qui sont conservées (avec erreur). + */ + test('compat bug : sw/uw saisis mais égaux aux équivalents => équivalents conservés', () => { + state.bug = true; + // sw saisi = 0,5 (fenêtre principale), sw seconde fenêtre = 0,5 => produit = 0,25 + const swEquiv = 0.5 * 0.5; + // uw saisi = 3 (fenêtre principale), uw seconde fenêtre = 3 => équivalent + const uwEquiv = 1 / (1 / 3 + 1 / 3 + 0.07); + + const bv = baseBv({ double_fenetre: 1, sw_saisi: 0.5, uw_saisi: 3 }); + // valeurs intermédiaires préexistantes égales aux équivalents des deux fenêtres + bv.donnee_intermediaire = { sw: swEquiv, uw: uwEquiv }; + bv.baie_vitree_double_fenetre = secondFenetre(); + + calc_bv(bv, 1); + + expect(bv.donnee_intermediaire.sw).toBeCloseTo(swEquiv, 9); + expect(bv.donnee_intermediaire.uw).toBeCloseTo(uwEquiv, 9); + expect(errorSpy).toHaveBeenCalled(); + }); +}); + +/** + * 6.3 Masques proches et lointains. + * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §6.3 + */ +describe('calc_bv - masques lointains non homogènes (fe2)', () => { + test('collection de masques : fe2 = 1 - somme des occultations', () => { + const bv = baseBv({ + masque_lointain_non_homogene_collection: { + masque_lointain_non_homogene: [ + { tv_coef_masque_lointain_non_homogene_id: 1 }, + { tv_coef_masque_lointain_non_homogene_id: 2 } + ] } - }; + }); + // omb = 20 par masque => fe2 = 1 - 0,2 - 0,2 = 0,6 + calc_bv(bv, 1); + expect(bv.donnee_intermediaire.fe2).toBeCloseTo(0.6, 9); + }); - calc_bv(bv, zc); + test('masque unique (objet, non tableau) : normalisé en tableau', () => { + const bv = baseBv({ + masque_lointain_non_homogene_collection: { + masque_lointain_non_homogene: { tv_coef_masque_lointain_non_homogene_id: 1 } + } + }); + calc_bv(bv, 1); + expect(bv.donnee_intermediaire.fe2).toBeCloseTo(0.8, 9); // 1 - 0,2 + }); - expect(bv.donnee_intermediaire.b).toBe(1); - expect(bv.donnee_intermediaire.ug).toBe(2.8); - expect(bv.donnee_intermediaire.uw).toBe(3); - expect(bv.donnee_intermediaire.u_menuiserie).toBe(3); - expect(bv.donnee_intermediaire.sw).toBeCloseTo(0.47000000000000003, PRECISION); - expect(bv.donnee_intermediaire.fe1).toBe(1); + test('collection sans masque : fe2 reste à 1', () => { + const bv = baseBv({ masque_lointain_non_homogene_collection: {} }); + calc_bv(bv, 1); expect(bv.donnee_intermediaire.fe2).toBe(1); }); - test('calcul de déperdition pour les murs de 2187E0982013C baie 6', () => { - const zc = 3; - const bv = { - donnee_entree: { - description: 'Fenêtre 6 Ouest - Fenêtres battantes bois, orientées Ouest, simple vitrage', - enum_type_adjacence_id: '1', - surface_totale_baie: 0.16, - nb_baie: 1, - tv_ug_id: 1, - enum_type_vitrage_id: '1', - enum_inclinaison_vitrage_id: '3', - enum_methode_saisie_perf_vitrage_id: '1', - tv_uw_id: 562, - enum_type_materiaux_menuiserie_id: '3', - enum_type_baie_id: '4', - double_fenetre: 0, - uw_1: 5.4, - sw_1: 0.52, - enum_type_fermeture_id: '1', - presence_retour_isolation: 0, - largeur_dormant: 5, - tv_sw_id: 89, - enum_type_pose_id: '3', - enum_orientation_id: '4', - tv_coef_masque_proche_id: 19, - masque_lointain_non_homogene_collection: '' - }, - donnee_intermediaire: { - b: 1, - ug: 5.8, - uw: 5.4, - u_menuiserie: 5.4, - sw: 0.52, - fe1: 1, - fe2: 1 + test('occultation supérieure à 100% : fe2 borné à 0', () => { + rows.coef_masque_lointain_non_homoge = { omb: '150' }; + const bv = baseBv({ + masque_lointain_non_homogene_collection: { + masque_lointain_non_homogene: [{ tv_coef_masque_lointain_non_homogene_id: 1 }] } - }; + }); + calc_bv(bv, 1); + expect(bv.donnee_intermediaire.fe2).toBe(0); + }); - calc_bv(bv, zc); + test('aucune ligne omb trouvée : information consignée (console.log)', () => { + rows.coef_masque_lointain_non_homoge = null; + const bv = baseBv({ + masque_lointain_non_homogene_collection: { + masque_lointain_non_homogene: [{ tv_coef_masque_lointain_non_homogene_id: 1 }] + } + }); + calc_bv(bv, 1); + expect(logSpy).toHaveBeenCalledWith( + '!! pas de valeur forfaitaire trouvée pour coef_masque_lointain_non_homog !!' + ); + }); +}); - expect(bv.donnee_intermediaire.b).toBe(1); - expect(bv.donnee_intermediaire.ug).toBe(5.8); - expect(bv.donnee_intermediaire.uw).toBe(5.4); - expect(bv.donnee_intermediaire.u_menuiserie).toBe(5.4); - expect(bv.donnee_intermediaire.sw).toBe(0.52); +describe('calc_bv - masque proche (fe1)', () => { + test('sans identifiant de masque proche : fe1 = 1', () => { + const bv = baseBv(); + calc_bv(bv, 1); expect(bv.donnee_intermediaire.fe1).toBe(1); + }); + + test('avec identifiant : fe1 lu dans la table', () => { + const bv = baseBv({ tv_coef_masque_proche_id: 19 }); + calc_bv(bv, 1); + expect(bv.donnee_intermediaire.fe1).toBe(0.8); + }); + + test('identifiant présent mais aucune ligne : erreur émise', () => { + rows.coef_masque_proche = null; + const bv = baseBv({ tv_coef_masque_proche_id: 19 }); + calc_bv(bv, 1); + expect(errorSpy).toHaveBeenCalledWith( + '!! pas de valeur forfaitaire trouvée pour coef_masque_proche !!' + ); + }); +}); + +describe('calc_bv - masque lointain homogène (fe2)', () => { + test('sans identifiant : fe2 inchangé', () => { + const bv = baseBv(); + calc_bv(bv, 1); expect(bv.donnee_intermediaire.fe2).toBe(1); }); + + test('avec identifiant : fe2 lu dans la table (écrase la valeur par défaut)', () => { + const bv = baseBv({ tv_coef_masque_lointain_homogene_id: 3 }); + calc_bv(bv, 1); + expect(bv.donnee_intermediaire.fe2).toBe(0.9); + }); + + test('identifiant présent mais aucune ligne : information consignée (console.log)', () => { + rows.coef_masque_lointain_homogene = null; + const bv = baseBv({ tv_coef_masque_lointain_homogene_id: 3 }); + calc_bv(bv, 1); + expect(logSpy).toHaveBeenCalledWith( + '!! pas de valeur forfaitaire trouvée pour coef_masque_lointain_homogene !!' + ); + }); }); diff --git a/src/3.4_pont_thermique.spec.js b/src/3.4_pont_thermique.spec.js new file mode 100644 index 00000000..491f313d --- /dev/null +++ b/src/3.4_pont_thermique.spec.js @@ -0,0 +1,890 @@ +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 +})); + +/** + * `bug_for_bug_compat` est exposé via un accesseur adossé à un état hoisté, afin de pouvoir + * l'activer dans certains tests (reproduction de comportements bugués des DPE réels) tout en + * restant désactivé par défaut. + */ +const state = vi.hoisted(() => ({ bug: false })); + +vi.mock('./utils.js', () => ({ + tv: vi.fn(), + requestInput: (de, du, field) => de[field], + compareReferences: (a, b) => a === b, + get bug_for_bug_compat() { + return state.bug; + } +})); + +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 = [], pb = [], ph = [], bv = [], porte = [] } = {}) { + return { + enveloppe: { + mur_collection: { mur }, + plancher_bas_collection: { plancher_bas: pb }, + plancher_haut_collection: { plancher_haut: ph }, + baie_vitree_collection: { baie_vitree: bv }, + porte_collection: { porte } + } + }; +} + +/** Pont thermique en méthode de saisie forfaitaire (méthode 1). */ +function pontThermique(de = {}, k = 0.9) { + return { + donnee_entree: { + enum_methode_saisie_pont_thermique_id: '1', + enum_type_liaison_id: '12', + ...de + }, + donnee_intermediaire: { k } + }; +} + +let errorSpy; +let warnSpy; + +beforeEach(() => { + tv.mockReset(); + state.bug = false; + 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(); + }); +}); + +/** + * defaultValue : valeur de repli lorsque la référence ou la paroi associée n'est pas retrouvée. + * Atteinte ici via une description non reconnue (ni "A / B" ni "A-B"). + */ +describe('defaultValue - valeur de repli sur description non reconnue', () => { + function ptRepli(k, tv_pont_thermique_id = 7, pourcentage) { + const de = { + enum_type_liaison_id: '12', + type_liaison: 'refend / mur', + description: 'descriptionNonReconnue', + tv_pont_thermique_id + }; + if (pourcentage !== undefined) de.pourcentage_valeur_pont_thermique = pourcentage; + return pontThermique(de, k); + } + + test('k intermédiaire nul : repli à 0 sans accès à la table', () => { + const pt = ptRepli(0); + calc_pont_thermique(pt, '1', logement()); + expect(pt.donnee_intermediaire.k).toBe(0); + expect(tv).not.toHaveBeenCalled(); + }); + + test('valeur tabulée cohérente avec la valeur intermédiaire : valeur tabulée conservée', () => { + tv.mockReturnValue({ k: '0.9' }); + const pt = ptRepli(0.9); + calc_pont_thermique(pt, '1', logement()); + expect(tv).toHaveBeenCalledWith('pont_thermique', { tv_pont_thermique_id: 7 }); + expect(pt.donnee_intermediaire.k).toBe('0.9'); + }); + + test('valeur tabulée = moitié de la valeur intermédiaire : pas d’incohérence', () => { + // k = 0,9 et tabulé 1,8 : 0,9 === 1,8 / 2 => cohérent (facteur 0.5) + tv.mockReturnValue({ k: '1.8' }); + const pt = ptRepli(0.9); + calc_pont_thermique(pt, '1', logement()); + expect(pt.donnee_intermediaire.k).toBe('1.8'); + expect(errorSpy).not.toHaveBeenCalledWith(expect.stringContaining('Incohérence')); + }); + + test('valeur tabulée incohérente : valeur intermédiaire conservée et erreur émise', () => { + // k = 0,9, tabulé 0,45 : 0,9 !== 0,45 et 0,9 !== 0,225 => incohérence + tv.mockReturnValue({ k: '0.45' }); + const pt = ptRepli(0.9); + calc_pont_thermique(pt, '1', logement()); + expect(pt.donnee_intermediaire.k).toBe(0.9); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Incohérence')); + }); + + test('aucune ligne tabulée trouvée : la valeur intermédiaire sert de repli', () => { + tv.mockReturnValue(null); + const pt = ptRepli(0.9); + calc_pont_thermique(pt, '1', logement()); + expect(pt.donnee_intermediaire.k).toBe(0.9); + }); +}); + +/** + * 3.4 Reconstruction des références manquantes à partir de la description. + * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §3.4 + */ +describe('calc_pont_thermique - reconstruction des références via la description', () => { + test('description "mur / plancher" : la référence du mur est retrouvée (liaison refend, pas de 2e paroi)', () => { + tv.mockReturnValue({ k: '0.5', tv_pont_thermique_id: '42' }); + const mur = [ + { + donnee_entree: { + reference: 'M1', + description: 'Mur Nord donnant sur extérieur', + enum_type_adjacence_id: '1', + type_isolation: 'iti' + }, + donnee_utilisateur: {} + } + ]; + const pt = pontThermique({ + type_liaison: 'refend / mur', + description: 'Mur Nord / Refend intérieur' + }); + + calc_pont_thermique(pt, '1', logement({ mur })); + + expect(warnSpy).toHaveBeenCalled(); + expect(pt.donnee_entree.reference_1).toBe('M1'); + expect(pt.donnee_intermediaire.k).toBe(0.5); + }); + + test('description "mur-baie" (séparateur tiret), liaison menuiserie : les deux références sont retrouvées', () => { + tv.mockReturnValue({ k: '0.3', tv_pont_thermique_id: '50' }); + const mur = [ + { + donnee_entree: { + reference: 'M1', + description: 'Mur Est', + enum_type_adjacence_id: '1', + enum_materiaux_structure_mur_id: '1', + type_isolation: 'iti' + }, + donnee_utilisateur: {} + } + ]; + const bv = [ + { + donnee_entree: { + reference: 'B1', + description: 'Fenetre salon', + type_pose: 'tunnel', + presence_retour_isolation: 1, + largeur_dormant: 5 + }, + donnee_utilisateur: {} + } + ]; + const pt = pontThermique({ + type_liaison: 'menuiserie / mur', + description: 'Fenetre salon-Mur Est' + }); + + calc_pont_thermique(pt, '1', logement({ mur, bv })); + + expect(pt.donnee_entree.reference_1).toBe('M1'); + expect(pt.donnee_entree.reference_2).toBe('B1'); + expect(pt.donnee_intermediaire.k).toBe(0.3); + }); + + test('description non retrouvée dans les murs : repli sur la valeur intermédiaire', () => { + tv.mockReturnValue({ k: '0.9' }); + const mur = [ + { donnee_entree: { reference: 'M1', description: 'Mur Sud' }, donnee_utilisateur: {} } + ]; + const pt = pontThermique({ + type_liaison: 'refend / mur', + description: 'Mur Absent / Refend', + tv_pont_thermique_id: 7 + }); + + calc_pont_thermique(pt, '1', logement({ mur })); + + expect(pt.donnee_intermediaire.k).toBe('0.9'); + expect(errorSpy).toHaveBeenCalled(); + }); + + test('2e paroi non retrouvée dans la liaison : repli sur la valeur intermédiaire', () => { + tv.mockReturnValue({ k: '0.9' }); + const mur = [ + { + donnee_entree: { reference: 'M1', description: 'Mur Ouest' }, + donnee_utilisateur: {} + } + ]; + const pt = pontThermique({ + type_liaison: 'plancher bas / mur', + description: 'Mur Ouest / Plancher inexistant', + tv_pont_thermique_id: 7 + }); + + calc_pont_thermique(pt, '1', logement({ mur })); + + expect(pt.donnee_entree.reference_1).toBe('M1'); + expect(pt.donnee_intermediaire.k).toBe('0.9'); + expect(errorSpy).toHaveBeenCalled(); + }); + + test('liaison "plancher intermédiaire lourd / mur" non supportée : avertissement, aucune 2e paroi', () => { + tv.mockReturnValue({ k: '0.4', tv_pont_thermique_id: '60' }); + const mur = [ + { + donnee_entree: { + reference: 'M1', + description: 'Mur porteur', + enum_type_adjacence_id: '1', + type_isolation: 'iti' + }, + donnee_utilisateur: {} + } + ]; + const pt = pontThermique({ + type_liaison: 'plancher intermédiaire lourd / mur', + description: 'Mur porteur / Plancher intermédiaire' + }); + + calc_pont_thermique(pt, '1', logement({ mur })); + + expect(warnSpy).toHaveBeenCalled(); + expect(pt.donnee_intermediaire.k).toBe(0.4); + }); +}); + +/** + * 3.4 Murs de circulation avec pont thermique non nul (cas devant tout de même être pris en compte). + */ +describe('calc_pont_thermique - mur de circulation avec k non nul', () => { + test("type d'isolation inconnu, période avant 1948 : mur non isolé", () => { + tv.mockReturnValue({ k: '0.6', tv_pont_thermique_id: '70' }); + const mur = [ + { + donnee_entree: { + reference: 'M1', + enum_type_adjacence_id: '14', + type_isolation: 'inconnu', + periode_isolation: 'avant 1948' + }, + donnee_utilisateur: {} + } + ]; + const pt = pontThermique({ + type_liaison: 'refend / mur', + reference_1: 'M1', + description: 'refend' + }); + + calc_pont_thermique(pt, '1', logement({ mur })); + + expect(tv).toHaveBeenCalledWith('pont_thermique', { + enum_type_liaison_id: '12', + isolation_mur: '^non isolé$' + }); + expect(pt.donnee_intermediaire.k).toBe(0.6); + }); + + test("type d'isolation inconnu, période récente (par défaut période de construction) : isolation ITI", () => { + tv.mockReturnValue({ k: '0.6', tv_pont_thermique_id: '70' }); + const mur = [ + { + donnee_entree: { + reference: 'M1', + enum_type_adjacence_id: '1', + type_isolation: 'inconnu' + }, + donnee_utilisateur: {} + } + ]; + const pt = pontThermique({ + type_liaison: 'refend / mur', + reference_1: 'M1', + description: 'refend' + }); + + // pc_id = '5' => période de construction '1989-2000' (hors seuils "avant 1948"/"1948-1974") + calc_pont_thermique(pt, '5', logement({ mur })); + + expect(tv).toHaveBeenCalledWith('pont_thermique', { + enum_type_liaison_id: '12', + isolation_mur: '^iti$' + }); + }); +}); + +/** + * 3.4.1 / 3.4.3 Ponts thermiques plancher bas / mur et plancher haut lourd / mur. + * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §3.4.1 et §3.4.3 + */ +describe('calc_pont_thermique - liaisons plancher / mur', () => { + function murPlancher(murOverrides = {}, plancherOverrides = {}) { + const mur = [ + { + donnee_entree: { + reference: 'M1', + enum_type_adjacence_id: '1', + type_isolation: 'iti', + ...murOverrides + }, + donnee_utilisateur: {} + } + ]; + const plancher = { + donnee_entree: { reference: 'P1', type_isolation: 'ite', ...plancherOverrides }, + donnee_utilisateur: {} + }; + return { mur, plancher }; + } + + function ptPlancher(type_liaison) { + return pontThermique({ + type_liaison, + enum_type_liaison_id: '3', + reference_1: 'P1', + reference_2: 'M1', + description: 'plancher / mur' + }); + } + + test('plancher non retrouvé : repli sur la valeur intermédiaire', () => { + tv.mockReturnValue({ k: '0.9' }); + const { mur } = murPlancher(); + const pt = ptPlancher('plancher bas / mur'); + pt.donnee_entree.tv_pont_thermique_id = 7; + + calc_pont_thermique(pt, '1', logement({ mur })); + + expect(pt.donnee_intermediaire.k).toBe('0.9'); + expect(errorSpy).toHaveBeenCalled(); + }); + + test('plancher haut lourd sur plafond bois (type 9) : pont thermique négligé (k = 0)', () => { + const { mur, plancher } = murPlancher({}, { enum_type_plancher_haut_id: '9' }); + const pt = ptPlancher('plancher haut lourd / mur'); + + calc_pont_thermique(pt, '1', logement({ mur, ph: [plancher] })); + + expect(pt.donnee_intermediaire.k).toBe(0); + expect(tv).not.toHaveBeenCalled(); + }); + + test('plancher haut lourd sur mur ossature bois (matériau 18) : pont thermique négligé (k = 0)', () => { + const { mur, plancher } = murPlancher( + { enum_materiaux_structure_mur_id: '18' }, + { enum_type_plancher_haut_id: '1' } + ); + const pt = ptPlancher('plancher haut lourd / mur'); + + calc_pont_thermique(pt, '1', logement({ mur, ph: [plancher] })); + + expect(pt.donnee_intermediaire.k).toBe(0); + }); + + test('plancher bas entre solives bois (type 4) : pont thermique négligé (k = 0)', () => { + const { mur, plancher } = murPlancher({}, { enum_type_plancher_bas_id: '4' }); + const pt = ptPlancher('plancher bas / mur'); + + calc_pont_thermique(pt, '1', logement({ mur, pb: [plancher] })); + + expect(pt.donnee_intermediaire.k).toBe(0); + }); + + test('plancher bas sur mur ossature bois (matériau 16) : pont thermique négligé (k = 0)', () => { + const { mur, plancher } = murPlancher( + { enum_materiaux_structure_mur_id: '16' }, + { enum_type_plancher_bas_id: '1' } + ); + const pt = ptPlancher('plancher bas / mur'); + + calc_pont_thermique(pt, '1', logement({ mur, pb: [plancher] })); + + expect(pt.donnee_intermediaire.k).toBe(0); + }); + + test('plancher bas isolation connue : matcher isolation_plancher construit', () => { + tv.mockReturnValue({ k: '0.35', tv_pont_thermique_id: '80' }); + const { mur, plancher } = murPlancher( + {}, + { enum_type_plancher_bas_id: '1', type_isolation: 'ite' } + ); + const pt = ptPlancher('plancher bas / mur'); + + calc_pont_thermique(pt, '1', logement({ mur, pb: [plancher] })); + + expect(tv).toHaveBeenCalledWith('pont_thermique', { + enum_type_liaison_id: '3', + isolation_mur: '^iti$', + isolation_plancher: '^ite$' + }); + expect(pt.donnee_intermediaire.k).toBe(0.35); + }); + + test('plancher bas isolation inconnue sur terre-plein ancien : plancher non isolé', () => { + tv.mockReturnValue({ k: '0.35', tv_pont_thermique_id: '80' }); + const { mur, plancher } = murPlancher( + {}, + { + enum_type_plancher_bas_id: '1', + type_isolation: 'inconnu', + type_adjacence: 'terre-plein', + periode_isolation: '1989-2000' + } + ); + const pt = ptPlancher('plancher bas / mur'); + + calc_pont_thermique(pt, '1', logement({ mur, pb: [plancher] })); + + // terre-plein : le seuil "non isolé" s'étend jusqu'à 1989-2000 + expect(tv).toHaveBeenCalledWith('pont_thermique', { + enum_type_liaison_id: '3', + isolation_mur: '^iti$', + isolation_plancher: 'non isolé' + }); + }); + + test('plancher bas isolation inconnue hors terre-plein, période issue de la construction : plancher ITE', () => { + tv.mockReturnValue({ k: '0.35', tv_pont_thermique_id: '80' }); + // periode_isolation absente => repli sur la période de construction (pc_id = '5' => 1989-2000) + const { mur, plancher } = murPlancher( + {}, + { + enum_type_plancher_bas_id: '1', + type_isolation: 'inconnu', + type_adjacence: 'extérieur' + } + ); + const pt = ptPlancher('plancher bas / mur'); + + calc_pont_thermique(pt, '5', logement({ mur, pb: [plancher] })); + + expect(tv).toHaveBeenCalledWith('pont_thermique', { + enum_type_liaison_id: '3', + isolation_mur: '^iti$', + isolation_plancher: '^ite$' + }); + }); + + test('plancher retrouvé via reference_2 : matcher construit', () => { + tv.mockReturnValue({ k: '0.35', tv_pont_thermique_id: '80' }); + const { mur, plancher } = murPlancher( + {}, + { enum_type_plancher_bas_id: '1', type_isolation: 'ite' } + ); + // reference_1 pointe le mur, reference_2 pointe le plancher + const pt = pontThermique({ + type_liaison: 'plancher bas / mur', + enum_type_liaison_id: '3', + reference_1: 'M1', + reference_2: 'P1', + description: 'mur / plancher' + }); + + calc_pont_thermique(pt, '1', logement({ mur, pb: [plancher] })); + + expect(tv).toHaveBeenCalledWith('pont_thermique', { + enum_type_liaison_id: '3', + isolation_mur: '^iti$', + isolation_plancher: '^ite$' + }); + }); + + test("plancher bas isolé mais type d'isolation inconnu : plancher ITE", () => { + tv.mockReturnValue({ k: '0.35', tv_pont_thermique_id: '80' }); + const { mur, plancher } = murPlancher( + {}, + { enum_type_plancher_bas_id: '1', type_isolation: "isolé mais type d'isolation inconnu" } + ); + const pt = ptPlancher('plancher bas / mur'); + + calc_pont_thermique(pt, '1', logement({ mur, pb: [plancher] })); + + expect(tv).toHaveBeenCalledWith('pont_thermique', { + enum_type_liaison_id: '3', + isolation_mur: '^iti$', + isolation_plancher: '^ite$' + }); + }); +}); + +/** + * 3.4.5 Menuiserie / mur. + * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §3.4.5 + */ +describe('calc_pont_thermique - liaison menuiserie / mur', () => { + function murMenuiserie(murOverrides = {}, menuiserieOverrides = {}) { + const mur = [ + { + donnee_entree: { + reference: 'M1', + enum_type_adjacence_id: '1', + enum_materiaux_structure_mur_id: '1', + type_isolation: 'iti', + ...murOverrides + }, + donnee_utilisateur: {} + } + ]; + const menuiserie = { + donnee_entree: { + reference: 'B1', + type_pose: 'tunnel', + presence_retour_isolation: 1, + largeur_dormant: 5, + ...menuiserieOverrides + }, + donnee_utilisateur: {} + }; + return { mur, menuiserie }; + } + + function ptMenuiserie() { + return pontThermique({ + type_liaison: 'menuiserie / mur', + enum_type_liaison_id: '20', + reference_1: 'B1', + reference_2: 'M1', + description: 'menuiserie / mur' + }); + } + + test('mur en isolation répartie (ITR) : le pont thermique ne dépend pas de la menuiserie', () => { + tv.mockReturnValue({ k: '0.2', tv_pont_thermique_id: '90' }); + const { mur } = murMenuiserie({ type_isolation: 'itr' }); + const pt = ptMenuiserie(); + + calc_pont_thermique(pt, '1', logement({ mur })); + + expect(tv).toHaveBeenCalledWith('pont_thermique', { + enum_type_liaison_id: '20', + isolation_mur: '^itr$' + }); + expect(pt.donnee_intermediaire.k).toBe(0.2); + }); + + test('menuiserie non retrouvée : repli sur la valeur intermédiaire', () => { + tv.mockReturnValue({ k: '0.9' }); + const { mur } = murMenuiserie(); + const pt = ptMenuiserie(); + pt.donnee_entree.tv_pont_thermique_id = 7; + + calc_pont_thermique(pt, '1', logement({ mur })); + + expect(pt.donnee_intermediaire.k).toBe('0.9'); + expect(errorSpy).toHaveBeenCalled(); + }); + + test('menuiserie retrouvée : matcher pose / retour isolation / largeur dormant', () => { + tv.mockReturnValue({ k: '0.25', tv_pont_thermique_id: '91' }); + const { mur, menuiserie } = murMenuiserie(); + const pt = ptMenuiserie(); + + calc_pont_thermique(pt, '1', logement({ mur, bv: [menuiserie] })); + + expect(tv).toHaveBeenCalledWith('pont_thermique', { + enum_type_liaison_id: '20', + isolation_mur: '^iti$', + type_pose: 'tunnel', + presence_retour_isolation: 1, + largeur_dormant: 5 + }); + expect(pt.donnee_intermediaire.k).toBe(0.25); + }); + + test('pose "sans objet" ramenée à une pose tunnel', () => { + tv.mockReturnValue({ k: '0.25', tv_pont_thermique_id: '91' }); + const { mur, menuiserie } = murMenuiserie({}, { type_pose: 'sans objet' }); + const pt = ptMenuiserie(); + + calc_pont_thermique(pt, '1', logement({ mur, bv: [menuiserie] })); + + expect(tv).toHaveBeenCalledWith( + 'pont_thermique', + expect.objectContaining({ type_pose: 'tunnel' }) + ); + }); + + test('type de pose absent (sans compatibilité bug) : pose tunnel par défaut', () => { + tv.mockReturnValue({ k: '0.25', tv_pont_thermique_id: '91' }); + const { mur, menuiserie } = murMenuiserie({}, { type_pose: undefined }); + const pt = ptMenuiserie(); + + calc_pont_thermique(pt, '1', logement({ mur, bv: [menuiserie] })); + + expect(tv).toHaveBeenLastCalledWith( + 'pont_thermique', + expect.objectContaining({ type_pose: 'tunnel' }) + ); + }); + + test('type de pose absent (compatibilité bug) : récupération via tv_pont_thermique_id', () => { + state.bug = true; + // 1er appel tv (récupération type_pose), 2e appel tv (ligne pont_thermique) + tv.mockReturnValueOnce({ type_pose: 'nu extérieur' }); + tv.mockReturnValue({ k: '0.25', tv_pont_thermique_id: '91' }); + const { mur, menuiserie } = murMenuiserie({}, { type_pose: undefined }); + const pt = ptMenuiserie(); + pt.donnee_entree.tv_pont_thermique_id = 7; + + calc_pont_thermique(pt, '1', logement({ mur, bv: [menuiserie] })); + + expect(tv).toHaveBeenLastCalledWith( + 'pont_thermique', + expect.objectContaining({ type_pose: 'nu extérieur' }) + ); + expect(errorSpy).toHaveBeenCalled(); + }); + + test('type de pose absent (compatibilité bug) sans ligne tv : pose tunnel par défaut', () => { + state.bug = true; + tv.mockReturnValueOnce(null); // récupération type_pose échoue + tv.mockReturnValue({ k: '0.25', tv_pont_thermique_id: '91' }); + const { mur, menuiserie } = murMenuiserie({}, { type_pose: undefined }); + const pt = ptMenuiserie(); + pt.donnee_entree.tv_pont_thermique_id = 7; + + calc_pont_thermique(pt, '1', logement({ mur, bv: [menuiserie] })); + + expect(tv).toHaveBeenLastCalledWith( + 'pont_thermique', + expect.objectContaining({ type_pose: 'tunnel' }) + ); + }); +}); + +/** + * Recherche du mur par la référence de la baie vitrée (comportement bugué reproduit). + */ +describe('calc_pont_thermique - récupération du mur via la baie vitrée (compatibilité bug)', () => { + test('mur retrouvé grâce à reference_paroi de la baie vitrée', () => { + state.bug = true; + tv.mockReturnValue({ k: '0.5', tv_pont_thermique_id: '42' }); + const mur = [ + { + donnee_entree: { + reference: 'M1', + enum_type_adjacence_id: '1', + type_isolation: 'iti' + }, + donnee_utilisateur: {} + } + ]; + const bv = [{ donnee_entree: { reference: 'BV1', reference_paroi: '0' } }]; + const pt = pontThermique({ + type_liaison: 'refend / mur', + reference_1: 'BV1', + description: 'refend' + }); + + calc_pont_thermique(pt, '1', logement({ mur, bv })); + + expect(pt.donnee_intermediaire.k).toBe(0.5); + expect(tv).toHaveBeenCalledWith('pont_thermique', { + enum_type_liaison_id: '12', + isolation_mur: '^iti$' + }); + expect(errorSpy).toHaveBeenCalled(); + }); + + test('reference_paroi ne correspondant pas à l’index du mur : aucun mur retrouvé (isolation ITI par défaut)', () => { + state.bug = true; + tv.mockReturnValue({ k: '0.5', tv_pont_thermique_id: '42' }); + const mur = [ + { + donnee_entree: { reference: 'M1', enum_type_adjacence_id: '1', type_isolation: 'iti' }, + donnee_utilisateur: {} + } + ]; + // reference_paroi = 5 alors que l'index du mur est 0 => pas de correspondance + const bv = [{ donnee_entree: { reference: 'BV1', reference_paroi: '5' } }]; + const pt = pontThermique({ + type_liaison: 'refend / mur', + reference_1: 'BV1', + description: 'refend' + }); + + calc_pont_thermique(pt, '1', logement({ mur, bv })); + + expect(tv).toHaveBeenCalledWith('pont_thermique', { + enum_type_liaison_id: '12', + isolation_mur: '^iti' + }); + expect(pt.donnee_intermediaire.k).toBe(0.5); + }); +}); + +/** + * Collections d'enveloppe absentes et absence de ligne tabulée finale. + */ +describe('calc_pont_thermique - cas limites', () => { + test('collections absentes : chaque liste retombe sur un tableau vide (repli valeur intermédiaire)', () => { + tv.mockReturnValue({ k: '0.9' }); + const logementVide = { + enveloppe: { + mur_collection: {}, + plancher_bas_collection: {}, + plancher_haut_collection: {}, + baie_vitree_collection: {}, + porte_collection: {} + } + }; + const pt = pontThermique({ + type_liaison: 'refend / mur', + description: 'descriptionNonReconnue', + tv_pont_thermique_id: 7 + }); + + calc_pont_thermique(pt, '1', logementVide); + + expect(pt.donnee_intermediaire.k).toBe('0.9'); + }); + + test('aucune ligne tabulée pour le pont thermique : erreur émise', () => { + tv.mockReturnValue(null); + const mur = [ + { + donnee_entree: { + reference: 'M1', + enum_type_adjacence_id: '1', + type_isolation: 'iti' + }, + donnee_utilisateur: {} + } + ]; + const pt = pontThermique({ + type_liaison: 'refend / mur', + reference_1: 'M1', + description: 'refend' + }); + + calc_pont_thermique(pt, '1', logement({ mur })); + + expect(errorSpy).toHaveBeenCalledWith( + '!! pas de valeur forfaitaire trouvée pour pont_thermique (k) !!' + ); + expect(pt.donnee_intermediaire.k).toBeUndefined(); + }); +}); diff --git a/src/3_deperdition.spec.js b/src/3_deperdition.spec.js new file mode 100644 index 00000000..349ecf75 --- /dev/null +++ b/src/3_deperdition.spec.js @@ -0,0 +1,397 @@ +import { afterEach, 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_pont_thermique } = await import('./3.4_pont_thermique.js'); +const { default: calc_ventilation } = await import('./4_ventilation.js'); + +let errorSpy; + +beforeEach(() => { + vi.mocked(calc_mur).mockReset(); + vi.mocked(calc_pont_thermique).mockReset(); + vi.mocked(calc_ventilation).mockReset(); + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); +}); + +afterEach(() => { + errorSpy.mockRestore(); +}); + +/** + * 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, + deperdition_pont_thermique +} = {}) { + const deperdition = { deperdition_mur }; + if (deperdition_pont_thermique !== undefined) { + deperdition.deperdition_pont_thermique = deperdition_pont_thermique; + } + return { + numero_dpe: 'TEST', + logement: { + sortie: { deperdition }, + 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 } + } + }; +} + +/** Fabrique une paroi surfacique générique. */ +function paroi(surfaceField, surface, uField, u, b, adjacence = '1') { + return { + donnee_entree: { [surfaceField]: surface, enum_type_adjacence_id: adjacence }, + donnee_intermediaire: { [uField]: u, b } + }; +} + +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); + }); + + /** + * calc_Sdep (surface déperditive transmise à la ventilation) exclut les parois à coefficient + * b nul et les parois adjacentes à un local non déperditif. Les parois à déperdition nulle + * (b = 0) sont malgré tout parcourues par les réductions de déperdition (branche `|| 0`). + */ + test('calc_Sdep exclut les parois à b nul et les locaux non déperditifs', () => { + const mur = [ + paroi('surface_paroi_opaque', 10, 'umur', 0.5, 0, '1'), // b = 0 => exclu de Sdep + paroi('surface_paroi_opaque', 20, 'umur', 0.4, 1, '2'), // local non déperditif => exclu + paroi('surface_paroi_opaque', 30, 'umur', 0.5, 1, '1') // pris en compte : +30 + ]; + const ph = [ + paroi('surface_paroi_opaque', 7, 'uph', 0.2, 0, '1'), // b = 0 => exclu de Sdep + paroi('surface_paroi_opaque', 8, 'uph', 0.2, 1, '1') // pris en compte : +8 + ]; + const pb = [ + paroi('surface_paroi_opaque', 6, 'upb_final', 0.3, 0), // b = 0 (branche `|| 0`) + paroi('surface_paroi_opaque', 12, 'upb_final', 0.3, 1) + ]; + const porte = [ + { donnee_entree: { surface_porte: 2 }, donnee_intermediaire: { uporte: 1.5, b: 0 } }, // exclu + { donnee_entree: { surface_porte: 3 }, donnee_intermediaire: { uporte: 1.5, b: 1 } } // +3 + ]; + const bv = [ + { + donnee_entree: { surface_totale_baie: 5 }, + donnee_intermediaire: { u_menuiserie: 2, b: 0 } + }, // exclu + { donnee_entree: { surface_totale_baie: 4 }, donnee_intermediaire: { u_menuiserie: 2, b: 1 } } // +4 + ]; + const ventilation = [{ donnee_intermediaire: { hvent: 0, hperm: 0 } }]; + + // d_mur = 0 + 20*0,4 + 30*0,5 = 23 (déclaré identique => conservé sans divergence) + const dpe = makeDpe({ mur, ph, pb, bv, porte, ventilation, deperdition_mur: 23 }); + const ret = calc_deperdition({ enum_periode_construction_id: '1' }, 'h1a', 'th', '0', dpe, 100); + + // Sdep transmis à calc_ventilation : 30 (mur) + 8 (ph) + 3 (porte) + 4 (bv) = 45 + const sdepTransmis = vi.mocked(calc_ventilation).mock.calls[0][3]; + expect(sdepTransmis).toBeCloseTo(45, 9); + + expect(ret.deperdition_mur).toBeCloseTo(23, 9); + expect(ret.deperdition_plancher_haut).toBeCloseTo(1.6, 9); // 0 + 8*0,2 + expect(ret.deperdition_plancher_bas).toBeCloseTo(3.6, 9); // 0 + 12*0,3 + expect(ret.deperdition_porte).toBeCloseTo(4.5, 9); // 0 + 3*1,5 + expect(ret.deperdition_baie_vitree).toBeCloseTo(8, 9); // 0 + 4*2 + }); + + /** + * Collections d'enveloppe absentes : chaque liste retombe sur son tableau vide par défaut + * (`|| []`) et l'agrégation produit des déperditions nulles. + */ + test('collections absentes : chaque liste retombe sur un tableau vide', () => { + const dpe = { + numero_dpe: 'TEST', + logement: { + sortie: { deperdition: { deperdition_mur: 0 } }, + enveloppe: { + mur_collection: {}, + plancher_bas_collection: {}, + plancher_haut_collection: {}, + porte_collection: {}, + baie_vitree_collection: {}, + pont_thermique_collection: {} + }, + ventilation_collection: {} + } + }; + + const ret = calc_deperdition({ enum_periode_construction_id: '1' }, 'h1a', 'th', '0', dpe, 100); + expect(ret.deperdition_enveloppe).toBe(0); + }); +}); + +/** + * 3.4 Comparaison des déperditions calculées et déclarées. + * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §3.4 + */ +describe('totalDeperditionMurs - arbitrage entre valeur calculée et déclarée', () => { + /** Un mur unique : Umur = 10 * 0,5 * 1 = 5. */ + const mur = () => [ + { + donnee_entree: { surface_paroi_opaque: 10, enum_type_adjacence_id: '1' }, + donnee_intermediaire: { umur: 0.5, b: 1 } + } + ]; + + test('déclaré supérieur au calculé : le total déclaré est conservé', () => { + const dpe = makeDpe({ + mur: mur(), + ventilation: [{ donnee_intermediaire: { hvent: 0, hperm: 0 } }], + deperdition_mur: 6 + }); + const ret = calc_deperdition({ enum_periode_construction_id: '1' }, 'h1a', 'th', '0', dpe, 100); + + expect(ret.deperdition_mur).toBe(6); + expect(errorSpy).toHaveBeenCalled(); + }); + + test('déclaré inférieur au calculé : le total calculé est conservé', () => { + const dpe = makeDpe({ + mur: mur(), + ventilation: [{ donnee_intermediaire: { hvent: 0, hperm: 0 } }], + deperdition_mur: 4 + }); + const ret = calc_deperdition({ enum_periode_construction_id: '1' }, 'h1a', 'th', '0', dpe, 100); + + expect(ret.deperdition_mur).toBeCloseTo(5, 9); + expect(errorSpy).toHaveBeenCalled(); + }); +}); + +describe('totalDeperditionPontThermique - arbitrage entre valeur calculée et déclarée', () => { + /** + * Ponts thermiques calculés identiques aux déclarés (mêmes k) mais total déclaré divergent : + * le total déclaré est conservé. Le premier pont thermique (l = 0) exerce la branche `|| 0` + * de la réduction, le second porte un pourcentage de prise en compte. + */ + test('ponts thermiques identiques mais total déclaré divergent : total déclaré conservé', () => { + const pt = [ + { donnee_entree: { l: 0 }, donnee_intermediaire: { k: 0 } }, // Upt = 0 (branche `|| 0`) + { + donnee_entree: { l: 4, pourcentage_valeur_pont_thermique: 0.5 }, + donnee_intermediaire: { k: 0.5 } + } // Upt = 4 * 0,5 * 0,5 = 1 + ]; + const dpe = makeDpe({ + pt, + ventilation: [{ donnee_intermediaire: { hvent: 0, hperm: 0 } }], + deperdition_pont_thermique: 3 // total calculé = 1, déclaré = 3 + }); + const ret = calc_deperdition({ enum_periode_construction_id: '1' }, 'h1a', 'th', '0', dpe, 100); + + expect(ret.deperdition_pont_thermique).toBe(3); + expect(errorSpy).toHaveBeenCalled(); + }); + + /** + * Lorsque les ponts thermiques calculés diffèrent des déclarés (module de calcul modifiant k), + * la comparaison échoue et le total calculé est utilisé. + */ + test('ponts thermiques calculés divergents des déclarés : total calculé conservé', () => { + vi.mocked(calc_pont_thermique).mockImplementation((pt) => { + pt.donnee_intermediaire.k = 0.8; + }); + const pt = [{ donnee_entree: { l: 4 }, donnee_intermediaire: { k: 0.5 } }]; + const dpe = makeDpe({ + pt, + ventilation: [{ donnee_intermediaire: { hvent: 0, hperm: 0 } }], + deperdition_pont_thermique: 5 + }); + const ret = calc_deperdition({ enum_periode_construction_id: '1' }, 'h1a', 'th', '0', dpe, 100); + + // Upt = 4 * 0,8 * 1 = 3,2 (valeur calculée, différente du déclaré 5) + expect(ret.deperdition_pont_thermique).toBeCloseTo(3.2, 9); + }); +}); diff --git a/src/4_ventilation.js b/src/4_ventilation.js index 263ea59e..e8e9be09 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 00000000..d02fe0c1 --- /dev/null +++ b/src/4_ventilation.spec.js @@ -0,0 +1,406 @@ +import { afterEach, 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' + ); + }); + + test('surface ventilée différente de Sh sans clé de répartition : divisée par 1', () => { + // Branche `de.cle_repartition_ventilation || 1` : surface_ventile conservée telle quelle + const vt = ventilation({ surface_ventile: 50, q4pa_conv_saisi: 1.3 }); + calc_ventilation(vt, CG, 'th', 80, 100, [], [], [], []); + + // surface_ventile = 50 / 1 => Hvent = 0,34 * 1,2 * 50 + expect(vt.donnee_intermediaire.hvent).toBeCloseTo(0.34 * 1.2 * 50, 9); + }); + + test("débits de ventilation introuvables : message d'erreur, débits non renseignés", () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + tv.mockImplementation((table) => (table === 'q4pa_conv' ? { q4pa_conv: '1.3' } : null)); + + const vt = ventilation({ q4pa_conv_saisi: 1.3 }); + calc_ventilation(vt, CG, 'th', 80, 100, [], [], [], []); + + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('debits_ventilation')); + errorSpy.mockRestore(); + }); + + test("valeur forfaitaire q4pa_conv introuvable : message d'erreur", () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + tv.mockImplementation((table) => (table === 'debits_ventilation' ? { ...ROW_DEBITS } : null)); + + const vt = ventilation(); + calc_ventilation(vt, CG, 'th', 80, 100, [], [], [], []); + + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('q4pa_conv')); + errorSpy.mockRestore(); + }); + + test('classification des surfaces : majorité isolée et joints présents => "1"/"1"', () => { + const vt = ventilation(); + const murs = [ + // b === 0 => paroi ignorée dans les deux sommes + { + donnee_intermediaire: { b: 0 }, + donnee_entree: { enum_type_isolation_id: '3', surface_paroi_opaque: 999 } + }, + // type inconnu (1) mais période d'isolation >= 3 => considérée isolée + { + donnee_intermediaire: { b: 1 }, + donnee_entree: { + enum_type_isolation_id: '1', + enum_periode_isolation_id: '3', + surface_paroi_opaque: 50 + } + }, + // type isolé connu (3) => isolée + { + donnee_intermediaire: { b: 1 }, + donnee_entree: { enum_type_isolation_id: '3', surface_paroi_opaque: 40 } + } + ]; + const phs = [ + // type non isolé (2) => non isolée + { + donnee_intermediaire: { b: 1 }, + donnee_entree: { enum_type_isolation_id: '2', surface_paroi_opaque: 10 } + }, + // type inconnu (1) période < 3 => non isolée + { + donnee_intermediaire: { b: 1 }, + donnee_entree: { + enum_type_isolation_id: '1', + enum_periode_isolation_id: '1', + surface_paroi_opaque: 5 + } + } + ]; + // surface_isolee = 90, surface_non_isolee = 15 => isolation_surfaces = '1' + const portes = [ + { donnee_entree: { presence_joint: 1, surface_porte: 2 } }, + { donnee_entree: { presence_joint: 0, surface_porte: 1 } } + ]; + const baies = [ + { donnee_entree: { presence_joint: 1, surface_totale_baie: 5 } }, + { donnee_entree: { presence_joint: 0, surface_totale_baie: 1 } } + ]; + // avec joint = 7, sans joint = 2 => ratio 7/9 > 0,5 => presence_joints_menuiserie = '1' + calc_ventilation(vt, CG, 'th', 80, 100, murs, phs, portes, baies); + + expect(tv).toHaveBeenCalledWith('q4pa_conv', { + enum_periode_construction_id: '1', + enum_methode_application_dpe_log_id: '1', + isolation_surfaces: '1', + presence_joints_menuiserie: '1' + }); + }); + + test('classification des surfaces : majorité non isolée et sans joints => "0"/"0"', () => { + const vt = ventilation(); + const murs = [ + { + donnee_intermediaire: { b: 1 }, + donnee_entree: { enum_type_isolation_id: '2', surface_paroi_opaque: 100 } + } + ]; + const baies = [{ donnee_entree: { presence_joint: 0, surface_totale_baie: 10 } }]; + + calc_ventilation(vt, CG, 'th', 80, 100, murs, [], [], baies); + + expect(tv).toHaveBeenCalledWith( + 'q4pa_conv', + expect.objectContaining({ isolation_surfaces: '0', presence_joints_menuiserie: '0' }) + ); + }); + + test('fiche technique façades exposées incohérente : valeur de la fiche imposée', () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + // Fiche "Non" => pfe = 0, alors que plusieurs_facade_exposee = 1 => incohérence signalée + const vt = ventilation({ + q4pa_conv_saisi: 1.3, + plusieurs_facade_exposee: 1, + ficheTechniqueFacadesExposees: { valeur: 'Non', description: 'FT façades' } + }); + calc_ventilation(vt, CG, 'th', 80, 100, [], [], [], []); + + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('plusieurs_facade_exposee')); + errorSpy.mockRestore(); + }); + + test('fiche technique façades exposées cohérente : aucun message', () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + // Fiche "Deux" => pfe = 1, cohérent avec plusieurs_facade_exposee = 1 + const vt = ventilation({ + q4pa_conv_saisi: 1.3, + plusieurs_facade_exposee: 1, + ficheTechniqueFacadesExposees: { valeur: 'Deux', description: 'FT façades' } + }); + calc_ventilation(vt, CG, 'th', 80, 100, [], [], [], []); + + expect(errorSpy).not.toHaveBeenCalled(); + errorSpy.mockRestore(); + }); + + test('fiche technique ventilation post 2012 incohérente : valeur forcée à 1', () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const vt = ventilation({ + q4pa_conv_saisi: 1.3, + ventilation_post_2012: 0, + ficheTechniqueVentilationPost2012: { description: 'FT ventilation' } + }); + calc_ventilation(vt, CG, 'th', 80, 100, [], [], [], []); + + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('ventilation_post_2012')); + expect(vt.donnee_entree.ventilation_post_2012).toBe(1); + errorSpy.mockRestore(); + }); +}); + +/** + * Comportement historique `bug_for_bug_compat` activé : le seuil des joints devient `>= 0,5` + * et les incohérences avec la ligne `q4pa_conv` déjà saisie dans le DPE sont signalées. + */ +describe('calc_ventilation - compatibilité bug_for_bug_compat activée', () => { + /** + * Recharge le module avec `bug_for_bug_compat = true`. Le double de `tv` renvoie une ligne + * `q4pa_conv` distincte selon que le matcher cible l'identifiant déjà saisi ou le matcher complet. + */ + async function chargerAvecBug(rowParId) { + vi.resetModules(); + const tvBug = vi.fn((table, matcher) => { + if (table === 'debits_ventilation') return { ...ROW_DEBITS }; + if (table === 'q4pa_conv') { + if (matcher.tv_q4pa_conv_id) return rowParId; + return { q4pa_conv: '1.3', tv_q4pa_conv_id: '9' }; + } + return null; + }); + vi.doMock('./utils.js', () => ({ + tv: tvBug, + requestInput: (de, du, field) => de[field], + requestInputID: (de, du, field) => de[`enum_${field}_id`], + bug_for_bug_compat: true + })); + vi.doMock('./5_conso_ventilation.js', () => ({ default: vi.fn() })); + const { default: calc_ventilation_bug } = await import('./4_ventilation.js'); + return { calc_ventilation_bug, tvBug }; + } + + afterEach(() => { + vi.doUnmock('./utils.js'); + vi.doUnmock('./5_conso_ventilation.js'); + vi.resetModules(); + }); + + /** Surfaces produisant isolation_surfaces = '1' et joints = '1'. */ + function surfacesIsolantes() { + return { + murs: [ + { + donnee_intermediaire: { b: 1 }, + donnee_entree: { enum_type_isolation_id: '3', surface_paroi_opaque: 100 } + } + ], + baies: [{ donnee_entree: { presence_joint: 1, surface_totale_baie: 10 } }] + }; + } + + test('ligne DPE incohérente : les deux écarts (isolation et joints) sont signalés', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const { calc_ventilation_bug } = await chargerAvecBug({ + q4pa_conv: '1.3', + tv_q4pa_conv_id: '9', + isolation_surfaces: '0', + presence_joints_menuiserie: '0', + description: 'ventilation' + }); + + const { murs, baies } = surfacesIsolantes(); + const vt = { + donnee_entree: { + enum_type_ventilation_id: '1', + tv_q4pa_conv_id: 9, + description: 'ventilation' + } + }; + calc_ventilation_bug(vt, CG, 'th', 80, 100, murs, [], [], baies); + + // Deux messages : isolation_surfaces et presence_joints_menuiserie + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('isolation_surfaces')); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('presence_joints_menuiserie')); + errorSpy.mockRestore(); + }); + + test('ligne DPE cohérente : aucun écart signalé', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const { calc_ventilation_bug } = await chargerAvecBug({ + q4pa_conv: '1.3', + tv_q4pa_conv_id: '9', + isolation_surfaces: '1', + presence_joints_menuiserie: '1' + }); + + const { murs, baies } = surfacesIsolantes(); + const vt = { + donnee_entree: { enum_type_ventilation_id: '1', tv_q4pa_conv_id: 9 } + }; + calc_ventilation_bug(vt, CG, 'th', 80, 100, murs, [], [], baies); + + expect(errorSpy).not.toHaveBeenCalled(); + errorSpy.mockRestore(); + }); + + test('ligne DPE saisie introuvable : aucune comparaison effectuée', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + // rowParId = null => la branche `if (rowQ4paConv)` est fausse + const { calc_ventilation_bug } = await chargerAvecBug(null); + + // Baie sans joint => ratio < 0,5 => branche '0' du seuil `>= 0,5` (mode bug) + const murs = [ + { + donnee_intermediaire: { b: 1 }, + donnee_entree: { enum_type_isolation_id: '3', surface_paroi_opaque: 100 } + } + ]; + const baies = [{ donnee_entree: { presence_joint: 0, surface_totale_baie: 10 } }]; + const vt = { + donnee_entree: { enum_type_ventilation_id: '1', tv_q4pa_conv_id: 9 } + }; + calc_ventilation_bug(vt, CG, 'th', 80, 100, murs, [], [], baies); + + expect(errorSpy).not.toHaveBeenCalled(); + errorSpy.mockRestore(); + }); +}); diff --git a/src/5_conso_ventilation.spec.js b/src/5_conso_ventilation.spec.js new file mode 100644 index 00000000..38be934d --- /dev/null +++ b/src/5_conso_ventilation.spec.js @@ -0,0 +1,186 @@ +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); + }); + + test("immeuble, ventilation hybride : ratio de temps d'utilisation immeuble (28/168)", () => { + // Branche `th !== 'maison'` du ratio hybride (28 au lieu de 14) + di.qvarep_conv = 2; + const de = { + type_ventilation: 'ventilation hybride de 2001 à 2012', + ventilation_post_2012: 1, + surface_ventile: 50 + }; + calc_pvent(di, de, du, 'immeuble'); + // hybride => type 'simple flux auto', post_2012 forcé à 0 => pvent_immeuble = 0.46 + const coef = 28 / (24 * 7); + expect(di.pvent_moy).toBeCloseTo(0.46 * 2 * 50 * coef, 10); + }); +}); + +/** + * Couverture exhaustive des libellés de type de ventilation (branches du switch). + * Chaque libellé doit être routé vers le bon groupe (naturelle => conso nulle, + * sinon simple flux auto / hygro / double flux avec une consommation positive). + */ +describe('calc_pvent - routage de tous les types de ventilation', () => { + const naturelles = [ + 'ventilation par ouverture des fenêtres', + "ventilation par entrées d'air hautes et basses", + 'ventilation naturelle par conduit', + "ventilation naturelle par conduit avec entrées d'air hygro", + 'puits climatique sans échangeur avant 2013', + 'puits climatique sans échangeur à partir de 2013', + 'puits climatique avec échangeur avant 2013', + 'puits climatique avec échangeur à partir de 2013' + ]; + + test.each(naturelles)('ventilation naturelle "%s" : consommation nulle', (type_ventilation) => { + const di = {}; + calc_pvent(di, { type_ventilation, ventilation_post_2012: 0 }, {}, 'maison'); + expect(di.conso_auxiliaire_ventilation).toBe(0); + expect(di.pvent_moy).toBeUndefined(); + }); + + const hybrides = [ + 'ventilation hybride avant 2001', + 'ventilation hybride de 2001 à 2012', + 'ventilation hybride après 2012', + "ventilation hybride avec entrées d'air hygro avant 2001", + "ventilation hybride avec entrées d'air hygro de 2001 à 2012", + "ventilation hybride avec entrées d'air hygro après 2012" + ]; + + test.each(hybrides)( + 'ventilation hybride "%s" : simple flux auto avec ratio 14/168 (maison)', + (type_ventilation) => { + const di = {}; + // Même avec ventilation_post_2012 = 1, l'hybride force post_2012 = 0 (=> 65 W) + calc_pvent(di, { type_ventilation, ventilation_post_2012: 1 }, {}, 'maison'); + expect(di.pvent_moy).toBeCloseTo(65 * (14 / (24 * 7)), 10); + } + ); + + const simpleFluxAuto = [ + 'ventilation mécanique sur conduit existant avant 2013', + 'ventilation mécanique sur conduit existant à partir de 2013', + 'vmc sf auto réglable avant 1982', + 'vmc sf auto réglable de 1982 à 2000', + 'vmc sf auto réglable de 2001 à 2012', + 'vmc sf auto réglable après 2012', + 'vmc sf gaz avant 2001', + 'vmc sf gaz de 2001 à 2012', + 'vmc sf gaz après 2012' + ]; + + test.each(simpleFluxAuto)( + 'simple flux auto "%s" : 65 W (maison, avant 2012)', + (type_ventilation) => { + const di = {}; + calc_pvent(di, { type_ventilation, ventilation_post_2012: 0 }, {}, 'maison'); + expect(di.pvent_moy).toBe(65); + } + ); + + const simpleFluxHygro = [ + 'vmc sf hygro b avant 2001', + 'vmc sf hygro b de 2001 à 2012', + 'vmc sf hygro b après 2012', + 'vmc sf hygro a avant 2001', + 'vmc sf hygro a de 2001 à 2012', + 'vmc sf hygro a après 2012', + 'vmc basse pression auto-réglable', + 'vmc basse pression hygro a', + 'vmc basse pression hygro b' + ]; + + test.each(simpleFluxHygro)( + 'simple flux hygro "%s" : 50 W (maison, avant 2012)', + (type_ventilation) => { + const di = {}; + calc_pvent(di, { type_ventilation, ventilation_post_2012: 0 }, {}, 'maison'); + expect(di.pvent_moy).toBe(50); + } + ); + + const doubleFlux = [ + 'vmc df individuelle avec échangeur avant 2013', + 'vmc df individuelle avec échangeur à partir de 2013', + 'vmc df collective avec échangeur avant 2013', + 'vmc df collective avec échangeur à partir de 2013', + 'vmc df sans échangeur avant 2013', + 'vmc df sans échangeur après 2012' + ]; + + test.each(doubleFlux)('double flux "%s" : 80 W (maison, avant 2012)', (type_ventilation) => { + const di = {}; + calc_pvent(di, { type_ventilation, ventilation_post_2012: 0 }, {}, 'maison'); + expect(di.pvent_moy).toBe(80); + }); +}); 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 00000000..0678cb6b --- /dev/null +++ b/src/6.2_surface_sud_equivalente.spec.js @@ -0,0 +1,138 @@ +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); + }); + + test("baie d'espace tampon sans donnée intermédiaire ni inclinaison : valeurs par défaut", () => { + // La baie de l'ets n'a ni donnee_intermediaire (=> {} : fe1/fe2 valent 1) ni + // enum_inclinaison_vitrage_id (=> repli sur l'inclinaison verticale, id 3). + const ets = { + donnee_intermediaire: { bver: 0.5, coef_transparence_ets: 0.8 }, + baie_ets_collection: { + baie_ets: { + donnee_entree: { + enum_type_adjacence_id: '1', + enum_orientation_id: '1', + surface_totale_baie: 4 + } + } + } + }; + + // orientation 'sud' + inclinaison par défaut 'verticale' => clé 'sud verticale' (c1j = 0.5) + // coeff = 0.8 * T + 0.024 = 0.664 ; Sstj = 4 * 0.5 * 0.664 = 1.328 + // pas de baie véranda => Ssdj = 0 ; ssInd = 1.328 ; SseVeranda = 1.328 * bver = 0.664 + expect(calc_sse_j([], ets, 'ca1', 'h1a', 'Janvier')).toBeCloseTo(0.664, 10); + }); + + test('les facteurs fe1/fe2 de la baie extérieure sont appliqués au calcul', () => { + const bv = baie({ adjacence: '1', surface: 2, sw: 0.5 }); + bv.donnee_intermediaire.fe1 = 0.5; + bv.donnee_intermediaire.fe2 = 0.8; + // getSsd = 2 * 0.5(c1) * 0.5(sw) * 0.5(fe1) * 0.8(fe2) = 0.2 + expect(calc_sse_j([bv], null, 'ca1', 'h1a', 'Janvier')).toBeCloseTo(0.2, 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); + }); +}); diff --git a/src/8_intermittence.spec.js b/src/8_intermittence.spec.js new file mode 100644 index 00000000..e77291c5 --- /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); + }); +}); diff --git a/src/9_besoin_ch.js b/src/9_besoin_ch.js index 368199fa..10e70881 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 00000000..695552c6 --- /dev/null +++ b/src/9_besoin_ch.spec.js @@ -0,0 +1,381 @@ +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() +})); + +const utilState = vi.hoisted(() => ({ bug: false })); +vi.mock('./utils.js', () => ({ + mois_liste: ['Janvier'], + get bug_for_bug_compat() { + return utilState.bug; + } +})); + +const { default: calc_besoin_ch, calc_Fj, calc_bvj } = await import('./9_besoin_ch.js'); +const { default: tvsBch } = await import('./tv.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); + }); +}); + +/** + * Branches complémentaires : récupération de stockage ECS, cas immeuble, + * degrés-heures nuls et compatibilité "bug for bug". + */ +describe('calc_besoin_ch - branches complémentaires', () => { + beforeEach(() => { + utilState.bug = false; + vi.mocked(calc_ai_j).mockReset().mockReturnValue(5000); + vi.mocked(calc_as_j).mockReset().mockReturnValue(2000); + vi.mocked(calc_sse_j).mockReset().mockReturnValue(10); + vi.mocked(calc_besoin_ecs_j).mockReset().mockReturnValue(10); + vi.mocked(calc_Qrec_gen_j).mockReset().mockReturnValue(0); + }); + + /** + * 11.4 - Plusieurs systèmes d'ECS : le besoin ECS est proratisé (1 / n). + */ + test('plusieurs installations ECS : prorata appliqué au besoin ECS', () => { + const instal_ecs = [ + { + donnee_entree: { enum_type_installation_id: '2', rdim: 1 }, + generateur_ecs_collection: { generateur_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(ret.pertes_distribution_ecs_recup).toBeGreaterThan(0); + }); + + /** + * 17.2.1.1 - Installation ECS simple en volume chauffé : récupération de + * stockage (Qgw) prise en compte, sauf ballon/générateur hors volume chauffé. + */ + test('installation ECS simple : récupération de stockage selon la position du ballon', () => { + const instal_ecs = [ + { + donnee_entree: { enum_type_installation_id: '1' }, // rdim absent => repli sur 1 + generateur_ecs_collection: { + generateur_ecs: [ + { + // en volume chauffé => récupération comptée + donnee_entree: { position_volume_chauffe_stockage: 1, position_volume_chauffe: 1 }, + donnee_intermediaire: { Qgw: 100 } + }, + { + // stockage hors volume chauffé => ignoré + donnee_entree: { position_volume_chauffe_stockage: 0, position_volume_chauffe: 1 }, + donnee_intermediaire: { Qgw: 100 } + }, + { + // générateur hors volume chauffé => ignoré + donnee_entree: { position_volume_chauffe_stockage: 1, position_volume_chauffe: 0 }, + donnee_intermediaire: { Qgw: 100 } + }, + { + // en volume chauffé mais sans Qgw => contribution nulle (Qgw || 0) + donnee_entree: { position_volume_chauffe_stockage: 1, position_volume_chauffe: 1 }, + donnee_intermediaire: {} + } + ] + } + } + ]; + const ret = calc_besoin_ch(0, 0, 0, 0, 100, 100, 3, instal_ecs, [], [], null, 'maison', 1); + // un seul ballon récupérable (Qgw = 100) => pertes de stockage strictement positives + expect(ret.pertes_stockage_ecs_recup).toBeGreaterThan(0); + }); + + /** + * 9.1.1 - Un générateur sans position en volume chauffé renseignée (?? 0) + * n'est pas retenu pour la récupération de génération. + */ + test("générateur sans position en volume chauffé renseignée n'est pas récupéré", () => { + 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 indéfinie => ?? 0 => exclu + } + ] + } + } + ]; + 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); + }); + + /** + * Immeuble : la récupération ECS est mutualisée (prorata rdim / nombre de + * logements) et le besoin ECS est agrégé avant application du facteur Qrec. + */ + test('cas immeuble : mutualisation de la récupération ECS (rdim / nbLogements)', () => { + const instal_ecs = [ + { + donnee_entree: { enum_type_installation_id: '1', rdim: 2 }, + generateur_ecs_collection: { generateur_ecs: [] } + } + ]; + const ret = calc_besoin_ch(0, 0, 0, 0, 100, 100, 3, instal_ecs, [], [], null, 'immeuble', 4); + expect(ret.pertes_distribution_ecs_recup).toBeGreaterThan(0); + }); + + /** + * Un mois sans degrés-heures (dh19/dh21 = 0) donne des déperditions bvj nulles. + */ + test('degrés-heures mensuels nuls : besoin nul sur le mois', () => { + const dh19 = tvsBch.dh19[0].ca1.Janvier.h1a; + const dh21 = tvsBch.dh21[0].ca1.Janvier.h1a; + tvsBch.dh19[0].ca1.Janvier.h1a = 0; + tvsBch.dh21[0].ca1.Janvier.h1a = 0; + try { + const ret = calc_besoin_ch(0, 0, 0, 0, 100, 100, 3, [], [], [], null, 'maison', 1); + expect(ret.besoin_ch).toBe(0); + expect(ret.besoin_ch_depensier).toBe(0); + } finally { + tvsBch.dh19[0].ca1.Janvier.h1a = dh19; + tvsBch.dh21[0].ca1.Janvier.h1a = dh21; + } + }); + + /** + * En mode "bug for bug", la récupération de génération est divisée par 1000 + * (reproduction d'une anomalie historique). + */ + test('compatibilité "bug for bug" : récupération de génération divisée par 1000', () => { + utilState.bug = true; + 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); + // 1000 / 1000 = 1 Wh récupéré sur le seul mois + expect(ret.pertes_generateur_ch_recup).toBeCloseTo(1, 9); + }); +}); diff --git a/src/9_chauffage.js b/src/9_chauffage.js index eeda15a7..3becc6d7 100644 --- a/src/9_chauffage.js +++ b/src/9_chauffage.js @@ -139,6 +139,8 @@ export default function calc_chauffage( Pnominal, zc ); + /* c8 ignore next 2 -- repli défensif inatteignable : donnee_utilisateur est toujours renseignée + par la première boucle sur gen_ch ci-dessus */ (gen.donnee_utilisateur = gen.donnee_utilisateur || {}).nbGenerateurCascade = gen_ch.length; calc_generateur_ch( @@ -293,6 +295,8 @@ export function tauxChargeForGenerator(installationChauffage, GV, caId, zcId, th ); installChauffageWithCombustion.forEach((installCh) => { + /* c8 ignore next 2 -- repli défensif inatteignable : genCombustion est toujours renseignée sur les + installations ajoutées à installChauffageWithCombustion */ (installCh.donnee_utilisateur.genCombustion || []).forEach((gen) => { const GV_ratio = th === 'immeuble' ? GV * (1 / (installCh.donnee_entree.rdim || 1)) : GV; gen.donnee_utilisateur.cdimref = Pn / (GV_ratio * (19 - tbase)); diff --git a/src/9_chauffage.spec.js b/src/9_chauffage.spec.js index 29caca98..91e7a1bb 100644 --- a/src/9_chauffage.spec.js +++ b/src/9_chauffage.spec.js @@ -1,102 +1,488 @@ -import calc_chauffage from './9_chauffage.js'; -import { describe, expect, test } from 'vitest'; - -describe('Recherche de bugs dans le calcul de la consommation', () => { - test('calcul de la consommation de chauffage pour 2475E2510509B', () => { - const Sh = 22.76; // Surface habitable - const zc_id = 1; // Zone climatique - const ca_id = 1; // Classe d'altitude - const inertie_id = 4; // Inertie du batiment - const map_id = 2; // Méthode d'application du DPE (enum_methode_application_dpe_log_id) - const bch = 7998.446638037776; // Besoin en chauffage - const bch_dep = 9714.925476692655; // Besoin en chauffage dépensier - const GV = 150.18558182166174; // Déperdition de l'enveloppe - const hsp = 2.5700000000000003; // Hauteur sous plafond - const ac = 1; // Période de construction - - const ch = { - donnee_entree: { - description: - 'Convecteur électrique NFC, NF** et NF*** avec programmateur pièce par pièce (système individuel)', - reference: '2024_07_11_09_30_44_722898200737262', - surface_chauffee: 22.76, - rdim: 1, - nombre_niveau_installation_ch: 1, - enum_cfg_installation_ch_id: '1', - enum_type_installation_id: '1', - enum_methode_calcul_conso_id: '1' - }, - emetteur_chauffage_collection: { - emetteur_chauffage: [ - { - donnee_entree: { - description: '', - reference: 'Emetteur:2024_07_11_09_30_44_722898200737262#1', - surface_chauffee: 22.76, - tv_rendement_emission_id: 1, - tv_rendement_distribution_ch_id: 1, - tv_rendement_regulation_id: 1, - enum_type_emission_distribution_id: '1', - tv_intermittence_id: 138, - reseau_distribution_isole: 0, - enum_equipement_intermittence_id: '4', - enum_type_regulation_id: '2', - enum_periode_installation_emetteur_id: '1', - enum_type_chauffage_id: '1', - enum_temp_distribution_ch_id: '1', - enum_lien_generateur_emetteur_id: '1' - } - } - ] - }, - generateur_chauffage_collection: { - generateur_chauffage: [ - { - donnee_entree: { - description: 'Electrique - Convecteur électrique NFC, NF** et NF***', - reference: 'Generateur:2024_07_11_09_30_44_722898200737262#1', - reference_generateur_mixte: '', - ref_produit_generateur_ch: 'Sans Objet', - enum_type_generateur_ch_id: '98', - enum_usage_generateur_id: '1', - enum_type_energie_id: '1', - position_volume_chauffe: 1, - tv_rendement_generation_id: 29, - identifiant_reseau_chaleur: '', - enum_methode_saisie_carac_sys_id: '1', - enum_lien_generateur_emetteur_id: '1' - } - } - ] - } - }; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +/** + * Dépendances mockées pour isoler `9_chauffage.js` : + * - `utils` : `requestInput`, `tv`, `tvColumnIDs` et la table `Tbase` ; + * - `9_emetteur_ch` : `calc_emetteur_ch` (renseignement des émetteurs) ; + * - `9_generateur_ch` : `calc_generateur_ch` (consommations, piloté par un + * espion qui écrit les données intermédiaires), `checkForGeneratorType` + * (classification combustion/PAC) et `hasConsoForAuxDistribution` ; + * - `13.2_generateur_combustion` / `13.2_generateur_combustion_ch` : calculs de + * combustion (no-op espionnés). + * + * On vérifie la logique d'orchestration propre au module : mise à l'échelle du + * besoin, facteur de couverture solaire, prorata entre générateurs (cascade, + * hybride) et répartition des auxiliaires. + */ +vi.mock('./utils.js', () => ({ + requestInput: vi.fn(), + tv: vi.fn(), + tvColumnIDs: vi.fn(), + Tbase: { + ca1: { h1: -9.5, h2: -6, h3: -3 }, + ca2: { h1: -11 } + } +})); + +vi.mock('./9_emetteur_ch.js', () => ({ + calc_emetteur_ch: vi.fn() +})); + +vi.mock('./9_generateur_ch.js', () => ({ + calc_generateur_ch: vi.fn(), + checkForGeneratorType: vi.fn(), + hasConsoForAuxDistribution: vi.fn() +})); + +vi.mock('./13.2_generateur_combustion.js', () => ({ + tv_generateur_combustion: vi.fn() +})); + +vi.mock('./13.2_generateur_combustion_ch.js', () => ({ + tv_temp_fonc_30_100: vi.fn() +})); + +vi.mock('./enums.js', () => ({ + default: { + classe_altitude: { 1: 'ca1', 2: 'ca2' }, + zone_climatique: { 1: 'h1a', 2: 'h2b', 3: 'h3c' } + } +})); + +const { default: calc_chauffage, tauxChargeForGenerator } = await import('./9_chauffage.js'); +const { requestInput, tv, tvColumnIDs } = await import('./utils.js'); +const { calc_emetteur_ch } = await import('./9_emetteur_ch.js'); +const { calc_generateur_ch, checkForGeneratorType, hasConsoForAuxDistribution } = await import( + './9_generateur_ch.js' +); +const { tv_generateur_combustion } = await import('./13.2_generateur_combustion.js'); +const { tv_temp_fonc_30_100 } = await import('./13.2_generateur_combustion_ch.js'); + +/** calc_generateur_ch mocké : écrit des consommations déterministes. */ +function stubGenerateur({ conso = 100, consoDep = 120, auxDistribution } = {}) { + vi.mocked(calc_generateur_ch).mockImplementation((_dpe, gen) => { + gen.donnee_intermediaire = gen.donnee_intermediaire || {}; + gen.donnee_intermediaire.conso_ch = conso; + gen.donnee_intermediaire.conso_ch_depensier = consoDep; + if (auxDistribution !== undefined) { + gen.donnee_intermediaire.conso_auxiliaire_distribution_ch = auxDistribution; + } + }); +} + +/** Émetteur minimal. */ +function emetteur(lien = '1') { + return { donnee_entree: { enum_lien_generateur_emetteur_id: lien } }; +} - calc_chauffage(ch, ca_id, zc_id, inertie_id, map_id, bch, bch_dep, GV, Sh, hsp, ac); +/** Générateur minimal. */ +function generateur(de = {}, di = {}) { + return { + donnee_entree: { + enum_type_generateur_ch_id: '98', + enum_lien_generateur_emetteur_id: '1', + ...de + }, + donnee_intermediaire: di + }; +} + +/** Installation de chauffage minimale. */ +function installation(gens, ems, deOverrides = {}) { + return { + donnee_entree: { + surface_chauffee: 100, + enum_cfg_installation_ch_id: '1', + ...deOverrides + }, + emetteur_chauffage_collection: { emetteur_chauffage: ems }, + generateur_chauffage_collection: { generateur_chauffage: gens } + }; +} + +/** DPE minimal (enveloppe non exploitée ici, calc_generateur_ch mocké). */ +const dpe = { logement: { enveloppe: {} } }; + +/** Appel standard de calc_chauffage. */ +function appel(ch, overrides = {}) { + const args = { + ca_id: 1, + zc_id: 1, + inertie_id: 3, + map_id: 2, + bch: 1000, + bch_dep: 1200, + GV: 150, + Sh: 100, + hsp: 2.5, + ac: 1, + ilpa: 0, + besoin_ch_mois: {}, + ...overrides + }; + calc_chauffage( + dpe, + ch, + args.ca_id, + args.zc_id, + args.inertie_id, + args.map_id, + args.bch, + args.bch_dep, + args.GV, + args.Sh, + args.hsp, + args.ac, + args.ilpa, + args.besoin_ch_mois + ); + return args; +} + +beforeEach(() => { + vi.mocked(requestInput).mockReset(); + vi.mocked(tv).mockReset(); + vi.mocked(tvColumnIDs).mockReset(); + vi.mocked(calc_emetteur_ch).mockReset(); + vi.mocked(calc_generateur_ch).mockReset(); + vi.mocked(checkForGeneratorType).mockReset(); + vi.mocked(hasConsoForAuxDistribution).mockReset(); + vi.mocked(tv_generateur_combustion).mockReset(); + vi.mocked(tv_temp_fonc_30_100).mockReset(); + + // valeurs par défaut : générateur ni combustion ni PAC, pas d'auxiliaire + vi.mocked(checkForGeneratorType).mockImplementation((_dpe, _de, _di, du) => { + du.isCombustionGenerator = false; + }); + vi.mocked(hasConsoForAuxDistribution).mockReturnValue(false); + vi.mocked(requestInput).mockReturnValue('installation de chauffage simple'); + vi.mocked(tv).mockReturnValue({ facteur_couverture_solaire: '0.4' }); + stubGenerateur(); +}); + +/** + * 9. Consommation de chauffage — orchestration + * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §9 + */ +describe('calc_chauffage - cas nominal (1 générateur, 1 émetteur)', () => { + test('agrège les consommations et renseigne les données intermédiaires', () => { + const ch = installation([generateur()], [emetteur('1')]); + appel(ch); expect(ch.donnee_intermediaire).toStrictEqual({ - besoin_ch: 7998.446638037776, - besoin_ch_depensier: 9714.925476692655, - conso_ch: 6322.706769289927, - conso_ch_depensier: 7679.569278179351 + besoin_ch: 1000, + besoin_ch_depensier: 1200, + conso_ch: 100, + conso_ch_depensier: 120 }); + expect(ch.donnee_utilisateur.Pnominal).toBe(0); + expect(calc_emetteur_ch).toHaveBeenCalledTimes(1); + expect(checkForGeneratorType).toHaveBeenCalledTimes(1); + }); + + test('renseigne les valeurs par défaut du générateur (fch, ratios, surface)', () => { + const ch = installation([generateur()], [emetteur('1')]); + appel(ch); + + const genDe = ch.generateur_chauffage_collection.generateur_chauffage[0].donnee_entree; + // Fch issu de la table de couverture solaire (0.4) + expect(genDe.fch).toBe(0.4); + expect(genDe.ratio_virtualisation).toBe(1); + expect(genDe.cle_repartition_ch).toBe(1); + expect(genDe.nombre_niveau_installation_ch).toBe(1); + expect(genDe.surface_chauffee).toBe(100); + }); + + test('conserve les valeurs déjà présentes sur le générateur', () => { + const ch = installation([generateur()], [emetteur('1')], {}); + // valeurs propres sur l'installation + ch.donnee_entree.ratio_virtualisation = 2; + ch.donnee_entree.cle_repartition_ch = 3; + ch.donnee_entree.nombre_niveau_installation_ch = 4; + appel(ch); + + const genDe = ch.generateur_chauffage_collection.generateur_chauffage[0].donnee_entree; + expect(genDe.ratio_virtualisation).toBe(2); + expect(genDe.cle_repartition_ch).toBe(3); + expect(genDe.nombre_niveau_installation_ch).toBe(4); + }); + + test('prorata unitaire : le besoin complet est transmis au générateur', () => { + const ch = installation([generateur()], [emetteur('1')]); + appel(ch, { bch: 800 }); + // 6e argument de calc_generateur_ch = bch * prorata (= 800 * 1) + expect(vi.mocked(calc_generateur_ch).mock.calls[0][5]).toBe(800); + }); +}); + +describe('calc_chauffage - mise à l’échelle du besoin selon la surface', () => { + test('besoin ajusté au prorata surface_chauffee / Sh quand elles diffèrent', () => { + const ch = installation([generateur()], [emetteur('1')]); + ch.donnee_entree.surface_chauffee = 50; + appel(ch, { bch: 1000, Sh: 100 }); + // bch mis à l'échelle : 1000 * 50 / 100 = 500 transmis au générateur + expect(vi.mocked(calc_generateur_ch).mock.calls[0][5]).toBe(500); + }); + + test('surface d’installation absente : la surface habitable Sh sert de repli', () => { + const ch = installation([generateur()], [emetteur('1')], { surface_chauffee: 0 }); + appel(ch, { Sh: 120 }); + // de.surface_chauffee falsy => genChDe.surface_chauffee = Sh expect( - ch.emetteur_chauffage_collection.emetteur_chauffage[0].donnee_intermediaire - ).toStrictEqual({ - i0: 0.86, - rendement_distribution: 1, - rendement_emission: 0.95, - rendement_regulation: 0.99 + ch.generateur_chauffage_collection.generateur_chauffage[0].donnee_entree.surface_chauffee + ).toBe(120); + }); +}); + +describe('calc_chauffage - facteur de couverture solaire (Fch)', () => { + test('facteur saisi directement pris tel quel', () => { + const ch = installation([generateur()], [emetteur('1')]); + ch.donnee_entree.fch_saisi = 0.7; + appel(ch); + expect(ch.generateur_chauffage_collection.generateur_chauffage[0].donnee_entree.fch).toBe(0.7); + expect(tv).not.toHaveBeenCalled(); + }); + + test('facteur lu en table quand non saisi', () => { + const ch = installation([generateur()], [emetteur('1')]); + vi.mocked(tv).mockReturnValue({ facteur_couverture_solaire: '0.55' }); + appel(ch); + expect(ch.generateur_chauffage_collection.generateur_chauffage[0].donnee_entree.fch).toBe(0.55); + }); + + test('aucune ligne trouvée : Fch nul, repli sur 0.5 et message d’erreur', () => { + const err = vi.spyOn(console, 'error').mockImplementation(() => {}); + const ch = installation([generateur()], [emetteur('1')]); + vi.mocked(tv).mockReturnValue(null); + appel(ch); + // Fch = null => genChDe.fch = null || 0.5 + expect(ch.generateur_chauffage_collection.generateur_chauffage[0].donnee_entree.fch).toBe(0.5); + expect(err).toHaveBeenCalled(); + err.mockRestore(); + }); +}); + +describe('calc_chauffage - générateurs à combustion', () => { + beforeEach(() => { + vi.mocked(checkForGeneratorType).mockImplementation((_dpe, _de, _di, du) => { + du.isCombustionGenerator = true; }); + vi.mocked(tvColumnIDs).mockReturnValue(['5']); + }); + + test('méthode de saisie ≠ 5 : recalcul des températures de fonctionnement', () => { + const ch = installation( + [generateur({ enum_type_generateur_ch_id: '5', enum_methode_saisie_carac_sys_id: '1' })], + [emetteur('1')] + ); + appel(ch); + expect(tv_generateur_combustion).toHaveBeenCalled(); + expect(tv_temp_fonc_30_100).toHaveBeenCalled(); + }); + + test('méthode 5 avec températures déjà connues : pas de recalcul', () => { + const ch = installation( + [ + generateur( + { enum_type_generateur_ch_id: '5', enum_methode_saisie_carac_sys_id: '5' }, + { temp_fonc_30: 30, temp_fonc_100: 45 } + ) + ], + [emetteur('1')] + ); + appel(ch); + expect(tv_temp_fonc_30_100).not.toHaveBeenCalled(); + }); + + test('méthode 5 mais temp_fonc_30 manquante : recalcul', () => { + const ch = installation( + [ + generateur( + { enum_type_generateur_ch_id: '5', enum_methode_saisie_carac_sys_id: '5' }, + { temp_fonc_100: 45 } + ) + ], + [emetteur('1')] + ); + appel(ch); + expect(tv_temp_fonc_30_100).toHaveBeenCalled(); + }); + + test('méthode 5 mais temp_fonc_100 manquante : recalcul', () => { + const ch = installation( + [ + generateur( + { enum_type_generateur_ch_id: '5', enum_methode_saisie_carac_sys_id: '5' }, + { temp_fonc_30: 30 } + ) + ], + [emetteur('1')] + ); + appel(ch); + expect(tv_temp_fonc_30_100).toHaveBeenCalled(); + }); + + test('type de générateur hors table temp_fonc_30 : pas de recalcul de températures', () => { + const ch = installation( + [generateur({ enum_type_generateur_ch_id: '99', enum_methode_saisie_carac_sys_id: '1' })], + [emetteur('1')] + ); + appel(ch); + expect(tv_generateur_combustion).toHaveBeenCalled(); + expect(tv_temp_fonc_30_100).not.toHaveBeenCalled(); + }); + + test('deux générateurs à combustion en cascade : prorata au prorata des puissances', () => { + const gen1 = generateur({ enum_type_generateur_ch_id: '5' }, { pn: 3000 }); + const gen2 = generateur({ enum_type_generateur_ch_id: '5' }, { pn: 1000 }); + const ch = installation([gen1, gen2], [emetteur('1')], { + enum_cfg_installation_ch_id: '1' + }); + appel(ch, { bch: 1000 }); + + // Pnominal = 4000 ; prorata gen1 = 3000/4000, gen2 = 1000/4000 + expect(ch.donnee_utilisateur.Pnominal).toBe(4000); + expect(vi.mocked(calc_generateur_ch).mock.calls[0][5]).toBeCloseTo(750, 9); + expect(vi.mocked(calc_generateur_ch).mock.calls[1][5]).toBeCloseTo(250, 9); + }); +}); + +describe('calc_chauffage - prorata entre générateurs', () => { + test('configuration ≠ 1 : prorata neutre (1 / (0 || 1))', () => { + const ch = installation([generateur()], [emetteur('1')], { + enum_cfg_installation_ch_id: '2' + }); + appel(ch, { bch: 900 }); + // nbCascadeForSameEmetteur = 0 => prorata = 1 / (0 || 1) = 1 + expect(vi.mocked(calc_generateur_ch).mock.calls[0][5]).toBe(900); + }); + + test('plusieurs émetteurs pour le même lien : pas de cascade (nbEmetteur ≠ 1)', () => { + const ch = installation( + [generateur({ enum_lien_generateur_emetteur_id: '1' })], + [emetteur('1'), emetteur('1')], + { enum_cfg_installation_ch_id: '1' } + ); + appel(ch, { bch: 900 }); + // nbEmetteur = 2 => nbCascadeForSameEmetteur = 0 => prorata = 1 + expect(vi.mocked(calc_generateur_ch).mock.calls[0][5]).toBe(900); + }); + + test('générateur PAC hybride avec scop : part PAC (0.8 en zone H1)', () => { + const gen = generateur({ enum_type_generateur_ch_id: '150' }, { scop: 3.5 }); + const ch = installation([gen], [emetteur('1')]); + appel(ch, { bch: 1000, zc_id: 1 }); + // hybrideProrata h1 pac = 0.8 + expect(vi.mocked(calc_generateur_ch).mock.calls[0][5]).toBeCloseTo(800, 9); + }); + + test('générateur PAC hybride sans scop/cop : part chaudière (0.2 en zone H1)', () => { + const gen = generateur({ enum_type_generateur_ch_id: '150' }, {}); + const ch = installation([gen], [emetteur('1')]); + appel(ch, { bch: 1000, zc_id: 1 }); + // hybrideProrata h1 chaudiere = 0.2 + expect(vi.mocked(calc_generateur_ch).mock.calls[0][5]).toBeCloseTo(200, 9); + }); +}); + +describe('calc_chauffage - auxiliaires de distribution répartis', () => { + test('répartition de la consommation d’auxiliaires sur les générateurs éligibles', () => { + vi.mocked(hasConsoForAuxDistribution).mockReturnValue(true); + stubGenerateur({ auxDistribution: 400 }); + + const ch = installation( + [ + generateur({ enum_type_generateur_ch_id: '110' }), + generateur({ enum_type_generateur_ch_id: '110' }) + ], + [emetteur('1')], + { enum_cfg_installation_ch_id: '2' } + ); + appel(ch); + + // 2 générateurs éligibles => 400 / 2 = 200 chacun + const gens = ch.generateur_chauffage_collection.generateur_chauffage; + expect(gens[0].donnee_intermediaire.conso_auxiliaire_distribution_ch).toBe(200); + expect(gens[1].donnee_intermediaire.conso_auxiliaire_distribution_ch).toBe(200); + }); + + test('aucun auxiliaire de distribution : pas de division', () => { + const ch = installation([generateur()], [emetteur('1')]); + appel(ch); + // conso_auxiliaire_distribution_ch non défini par le stub => aucune répartition expect( ch.generateur_chauffage_collection.generateur_chauffage[0].donnee_intermediaire - ).toStrictEqual({ - conso_auxiliaire_generation_ch: 0, - conso_auxiliaire_generation_ch_depensier: 0, - conso_ch: 6322.706769289927, - conso_ch_depensier: 7679.569278179351, - rendement_generation: 1, - rg: 1, - rg_dep: 1 - }); + .conso_auxiliaire_distribution_ch + ).toBeUndefined(); + }); +}); + +describe('calc_chauffage - initialisation défensive des données du générateur', () => { + test('générateur sans donnee_utilisateur ni donnee_intermediaire initiales', () => { + const gen = { + donnee_entree: { enum_type_generateur_ch_id: '98', enum_lien_generateur_emetteur_id: '1' } + }; + const ch = installation([gen], [emetteur('1')]); + appel(ch); + expect(gen.donnee_utilisateur.nbGenerateurCascade).toBe(1); + expect(gen.donnee_intermediaire.conso_ch).toBe(100); + }); +}); + +/** + * 13.2.1.2 Taux de charge des générateurs à combustion (cdimref) + * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §13.2.1.2 + */ +describe('tauxChargeForGenerator - taux de charge des générateurs à combustion', () => { + /** Installation avec un générateur à combustion. */ + function installCombustion(pnominal = 5000, rdim) { + return { + donnee_entree: { rdim }, + donnee_utilisateur: { Pnominal: pnominal }, + generateur_chauffage_collection: { + generateur_chauffage: [{ donnee_utilisateur: { isCombustionGenerator: true } }] + } + }; + } + + test('maison : GV utilisé directement pour le calcul du taux de charge', () => { + const install = installCombustion(5000); + tauxChargeForGenerator([install], 200, 1, 1, 'maison'); + + const gen = install.generateur_chauffage_collection.generateur_chauffage[0]; + // cdimref = Pn / (GV * (19 - tbase)) ; tbase = -9.5 (ca1/h1) + expect(gen.donnee_utilisateur.cdimref).toBeCloseTo(5000 / (200 * (19 + 9.5)), 9); + expect(gen.donnee_utilisateur.cdimrefDep).toBeCloseTo(5000 / (200 * (21 + 9.5)), 9); + }); + + test('immeuble : GV corrigé par le ratio de dimensionnement rdim', () => { + const install = installCombustion(5000, 2); + tauxChargeForGenerator([install], 200, 1, 1, 'immeuble'); + + const gen = install.generateur_chauffage_collection.generateur_chauffage[0]; + const gvRatio = 200 * (1 / 2); + expect(gen.donnee_utilisateur.cdimref).toBeCloseTo(5000 / (gvRatio * (19 + 9.5)), 9); + }); + + test('immeuble sans rdim : ratio de dimensionnement par défaut (1)', () => { + const install = installCombustion(5000); + tauxChargeForGenerator([install], 200, 1, 1, 'immeuble'); + + const gen = install.generateur_chauffage_collection.generateur_chauffage[0]; + // rdim absent => GV_ratio = GV + expect(gen.donnee_utilisateur.cdimref).toBeCloseTo(5000 / (200 * (19 + 9.5)), 9); + }); + + test('installation sans générateur à combustion : ignorée', () => { + const installSans = { + donnee_entree: {}, + donnee_utilisateur: {}, + generateur_chauffage_collection: { + generateur_chauffage: [{ donnee_utilisateur: { isCombustionGenerator: false } }] + } + }; + // aucune erreur, aucune donnée de taux de charge écrite + expect(() => tauxChargeForGenerator([installSans], 200, 1, 1, 'maison')).not.toThrow(); + expect(installSans.donnee_utilisateur.genCombustion).toBeUndefined(); }); }); diff --git a/src/9_conso_ch.spec.js b/src/9_conso_ch.spec.js index 2fbd9c3e..a804e835 100644 --- a/src/9_conso_ch.spec.js +++ b/src/9_conso_ch.spec.js @@ -1,256 +1,510 @@ -import { conso_ch } from './9_conso_ch.js'; -import { describe, expect, test } from 'vitest'; - -describe('Recherche de bugs dans le calcul de la consommation du chauffage', () => { - test('calcul de la consommation du chauffage pour 2475E2510509B', () => { - const di = { - rendement_generation: 1, - rg: 1, - rg_dep: 1, - conso_auxiliaire_generation_ch: 0, - conso_auxiliaire_generation_ch_depensier: 0 - }; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +/** + * Dépendances mockées pour isoler `9_conso_ch.js` : + * - `utils` : `mois_liste` (réduite) et `requestInputID` (lien générateur/émetteur) ; + * - `9_emetteur_ch` : `rendement_emission` (produit des rendements de l'émetteur) ; + * - `8_intermittence` : `calc_intermittence` (coefficient d'intermittence I0) ; + * - `tv` : profils mensuels dh14 / text / nref19 (valeurs contrôlées) ; + * - `enums` : mapping altitude / zone climatique. + * + * `isNil` (lodash-es) est un utilitaire déterministe conservé tel quel. + * On pilote les fonctions mockées pour vérifier la logique de composition + * propre au module (choix des coefficients, prorata de surface, base+appoint). + */ +vi.mock('./utils.js', () => ({ + mois_liste: ['Janvier', 'Février'], + requestInputID: vi.fn() +})); + +vi.mock('./9_emetteur_ch.js', () => ({ + rendement_emission: vi.fn() +})); + +vi.mock('./8_intermittence.js', () => ({ + calc_intermittence: vi.fn() +})); + +vi.mock('./tv.js', () => ({ + default: { + dh14: { + 0: { ca1: { Janvier: { h1a: 1000 }, Février: { h1a: 800 } } }, + 1: { ca1: { Janvier: { h1a: 1100 }, Février: { h1a: 900 } } } + }, + text: { + 0: { ca1: { Janvier: { h1a: 5 }, Février: { h1a: 7 } } }, + 1: { ca1: { Janvier: { h1a: 5 }, Février: { h1a: 7 } } } + }, + nref19: { + 0: { ca1: { Janvier: { h1a: 500 }, Février: { h1a: 400 } } }, + 1: { ca1: { Janvier: { h1a: 500 }, Février: { h1a: 400 } } } + } + } +})); + +vi.mock('./enums.js', () => ({ + default: { + zone_climatique: { 1: 'h1a' }, + classe_altitude: { 1: 'ca1' } + } +})); + +const { conso_ch } = await import('./9_conso_ch.js'); +const { default: tvs } = await import('./tv.js'); +const { requestInputID } = await import('./utils.js'); +const { rendement_emission } = await import('./9_emetteur_ch.js'); +const { calc_intermittence } = await import('./8_intermittence.js'); + +/** Émetteur minimal avec une valeur d'intermittence I0 propre. */ +function emetteur(i0, surface = 100, lien = '1') { + return { + donnee_entree: { enum_lien_generateur_emetteur_id: lien, surface_chauffee: surface }, + donnee_intermediaire: { i0 } + }; +} + +/** + * 9. Consommations de chauffage (Cch) + * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §9 + */ +describe('conso_ch - configuration par défaut (installation simple)', () => { + beforeEach(() => { + vi.mocked(requestInputID).mockReset(); + vi.mocked(rendement_emission).mockReset(); + vi.mocked(calc_intermittence).mockReset(); + // rendement d'émission global = 0.8 -> Ich émetteur = 1 / 0.8 = 1.25 + vi.mocked(rendement_emission).mockReturnValue(0.8); + // I0 renvoyé tel quel pour rendre le prorata vérifiable + vi.mocked(calc_intermittence).mockImplementation((_gv, _sh, _hsp, i0) => i0); + }); + + test('conso = coeff * (I0 / rendement_emission / rg) * bch (générateur électrique, 1 émetteur)', () => { + const di = { rg: 1, rg_dep: 1 }; + // enum électrique + installation simple => émetteur sélectionné par position + const de = { enum_type_generateur_ch_id: '98', surface_chauffee: 100 }; + const em = [emetteur(0.9)]; + + conso_ch( + di, + de, + {}, + 0, + 'installation de chauffage simple', + em, + 150, + 100, + 2.5, + 1000, + 2000, + -9.5, + 0, + 1, + 1 + ); + + // coeff = 1 ; emetteur_eq = 0.9 * 1.25 = 1.125 + expect(di.conso_ch).toBeCloseTo(1125, 9); + expect(di.conso_ch_depensier).toBeCloseTo(2250, 9); + // un seul émetteur => calc_intermittence appelé avec l'I0 de l'émetteur + expect(calc_intermittence).toHaveBeenCalledWith(150, 100, 2.5, 0.9); + }); + + test('coefficient de configuration : "chauffage solaire" applique (1 - Fch)', () => { + const di = { rg: 1, rg_dep: 1 }; + // non électrique => tous les émetteurs filtrés sont retenus + const de = { enum_type_generateur_ch_id: '10', surface_chauffee: 100, fch: 0.4 }; + const em = [emetteur(1)]; + + conso_ch( + di, + de, + {}, + 0, + 'installation de chauffage avec chauffage solaire', + em, + 150, + 100, + 2.5, + 1000, + 2000, + -9.5, + 0, + 1, + 1 + ); + + // coeff = 1 - 0.4 = 0.6 ; Ich = 1 * 1.25 = 1.25 + expect(di.conso_ch).toBeCloseTo(0.6 * 1.25 * 1000, 9); + }); + + test('sans fch, le coefficient utilise la valeur par défaut 0.5', () => { + const di = { rg: 1, rg_dep: 1 }; + const de = { enum_type_generateur_ch_id: '10', surface_chauffee: 100 }; + const em = [emetteur(1)]; + + conso_ch( + di, + de, + {}, + 0, + 'installation de chauffage avec chauffage solaire', + em, + 150, + 100, + 2.5, + 1000, + 2000, + -9.5, + 0, + 1, + 1 + ); + + // coeff = 1 - 0.5 = 0.5 + expect(di.conso_ch).toBeCloseTo(0.5 * 1.25 * 1000, 9); + }); + + test('coefficient absent pour la position demandée : repli sur 1', () => { + const di = { rg: 1, rg_dep: 1 }; + // non électrique pour conserver tous les émetteurs malgré la position 2 + const de = { enum_type_generateur_ch_id: '10', surface_chauffee: 100 }; + const em = [emetteur(0.9)]; + + conso_ch( + di, + de, + {}, + 2, + 'installation de chauffage simple', + em, + 150, + 100, + 2.5, + 1000, + 2000, + -9.5, + 0, + 1, + 1 + ); + + // coeff = 1 (aucune clé 2 dans la table) ; Ich = 0.9 * 1.25 + expect(di.conso_ch).toBeCloseTo(0.9 * 1.25 * 1000, 9); + }); + + test('plusieurs émetteurs : prorata par la surface chauffée de chaque émetteur', () => { + const di = { rg: 1, rg_dep: 1 }; + // non électrique => les deux émetteurs liés sont conservés + const de = { enum_type_generateur_ch_id: '10', surface_chauffee: 40 }; + vi.mocked(requestInputID).mockReturnValue('1'); + const em = [emetteur(1, 10, '1'), emetteur(2, 30, '1')]; + + conso_ch( + di, + de, + {}, + 0, + 'installation de chauffage simple', + em, + 150, + 100, + 2.5, + 1000, + 2000, + -9.5, + 0, + 1, + 1 + ); + + // emetteur_eq = (10/40)*1*1.25 + (30/40)*2*1.25 = 0.3125 + 1.875 = 2.1875 + expect(di.conso_ch).toBeCloseTo(2187.5, 9); + expect(rendement_emission).toHaveBeenCalledTimes(2); + }); + + test('générateur électrique avec position nulle : tous les émetteurs sont conservés (isNil)', () => { + const di = { rg: 1, rg_dep: 1 }; + const de = { enum_type_generateur_ch_id: '98', surface_chauffee: 40 }; + vi.mocked(requestInputID).mockReturnValue('1'); + const em = [emetteur(1, 10, '1'), emetteur(2, 30, '1')]; + + conso_ch( + di, + de, + {}, + null, + 'installation de chauffage simple', + em, + 150, + 100, + 2.5, + 1000, + 2000, + -9.5, + 0, + 1, + 1 + ); + + // les deux émetteurs pris en compte => emetteur_eq = 2.1875 + expect(di.conso_ch).toBeCloseTo(2187.5, 9); + }); +}); + +describe('conso_ch - convecteurs bi-jonction (individuel + collectif)', () => { + beforeEach(() => { + vi.mocked(requestInputID).mockReset(); + vi.mocked(rendement_emission).mockReset(); + vi.mocked(calc_intermittence).mockReset(); + vi.mocked(rendement_emission).mockReturnValue(0.8); + vi.mocked(calc_intermittence).mockImplementation((_gv, _sh, _hsp, i0) => i0); + }); + + test('somme des parts individuelle (coeff 0.4) et collective (coeff 0.6, I0 forcé à 1.03)', () => { + const di = { rg: 1, rg_dep: 1 }; + const de = { enum_type_generateur_ch_id: '10', surface_chauffee: 100, fch: 0.5 }; + const em = [emetteur(0.5)]; + + conso_ch( + di, + de, + {}, + 0, + 'convecteurs bi-jonction', + em, + 150, + 100, + 2.5, + 1000, + 2000, + -9.5, + 0, + 1, + 1 + ); + + // individuel : 0.4 * (0.5 / 0.8) * 1000 = 250 + // collectif : 0.6 * (1.03 / 0.8) * 1000 = 772.5 + expect(di.conso_ch).toBeCloseTo(250 + 772.5, 9); + expect(di.conso_ch_depensier).toBeCloseTo((250 + 772.5) * 2, 9); + // l'intermittence collective est calculée avec I0 = 1.03 + expect(calc_intermittence).toHaveBeenCalledWith(150, 100, 2.5, 1.03); + }); +}); - const de = { - description: 'Electrique - Convecteur électrique NFC, NF** et NF***', - reference: 'Generateur:2024_07_11_09_30_44_722898200737262#1', - reference_generateur_mixte: '', - ref_produit_generateur_ch: 'Sans Objet', - enum_type_generateur_ch_id: '98', - enum_usage_generateur_id: '1', - enum_type_energie_id: '1', - position_volume_chauffe: 1, - tv_rendement_generation_id: 29, - identifiant_reseau_chaleur: '', - enum_methode_saisie_carac_sys_id: '1', - enum_lien_generateur_emetteur_id: '1' +describe('conso_ch - installation collective base + appoint', () => { + beforeEach(() => { + vi.mocked(requestInputID).mockReset(); + vi.mocked(rendement_emission).mockReset(); + vi.mocked(calc_intermittence).mockReset(); + vi.mocked(rendement_emission).mockReturnValue(0.9); + vi.mocked(calc_intermittence).mockReturnValue(1); + }); + + /** Jeu d'installation base + appoint minimal. */ + function baseAppointArgs(pos, { pn = 5000 } = {}) { + const di = { rg: 1, rg_dep: 1 }; + const de = { surface_chauffee: 100, rdim: 1 }; + const emBase = { + donnee_entree: { surface_chauffee: 100 }, + donnee_intermediaire: { + i0: 0.9, + rendement_distribution: 0.95, + rendement_regulation: 0.99, + rendement_emission: 0.95 + } + }; + const emAppoint = { + donnee_entree: { surface_chauffee: 100 }, + donnee_intermediaire: { i0: 0.9 } }; + const genBase = { donnee_intermediaire: { pn, rendement_generation: 0.9 } }; + const genAppoint = { donnee_intermediaire: { pn: 0, rendement_generation: 0.9 } }; + const em_list = [emBase, emAppoint]; + const gen_ch_list = [genBase, genAppoint]; + const besoin_ch_mois = { Janvier: 3000, Février: 2000 }; + return [ + di, + de, + pos, + 'installation de chauffage collectif avec base + appoint', + em_list, + 150, + 100, + 2.5, + 50, + 60, + -9.5, + 0, + 1, + 1, + besoin_ch_mois, + 100, + gen_ch_list + ]; + } + + /** Adaptation à la signature réelle de `conso_ch`. */ + function callBaseAppoint(pos, opts) { + const [ + di, + de, + _pos, + cfg, + em_list, + GV, + Sh, + hsp, + bch, + bch_dep, + tbase, + ilpa, + ca_id, + zc_id, + besoin_ch_mois, + s_chauffee_inst, + gen_ch_list + ] = baseAppointArgs(pos, opts); + conso_ch( + di, + de, + {}, + _pos, + cfg, + em_list, + GV, + Sh, + hsp, + bch, + bch_dep, + tbase, + ilpa, + ca_id, + zc_id, + besoin_ch_mois, + s_chauffee_inst, + gen_ch_list + ); + return di; + } + + test('générateur de base (pos 0) : valeur de référence de régression', () => { + const di = callBaseAppoint(0); + // référence de régression (calcul mensuel base+appoint avec pn > 0) + expect(di.conso_ch).toBeCloseTo(6.172839506172839, 9); + }); - const du = { - enum_usage_generateur_id: ['1', '2', '3'], - enum_type_generateur_ch_id: [ - '1', - '2', - '3', - '4', - '5', - '6', - '7', - '8', - '9', - '10', - '11', - '12', - '13', - '14', - '15', - '16', - '17', - '18', - '19', - '20', - '21', - '22', - '23', - '24', - '25', - '26', - '27', - '28', - '29', - '30', - '31', - '32', - '33', - '34', - '35', - '36', - '37', - '38', - '39', - '40', - '41', - '42', - '43', - '44', - '45', - '46', - '47', - '48', - '49', - '50', - '51', - '52', - '53', - '54', - '55', - '56', - '57', - '58', - '59', - '60', - '61', - '62', - '63', - '64', - '65', - '66', - '67', - '68', - '69', - '70', - '71', - '72', - '73', - '74', - '75', - '76', - '77', - '78', - '79', - '80', - '81', - '82', - '83', - '84', - '85', - '86', - '87', - '88', - '89', - '90', - '91', - '92', - '93', - '94', - '95', - '96', - '97', - '98', - '99', - '100', - '101', - '102', - '103', - '104', - '105', - '106', - '107', - '108', - '109', - '110', - '111', - '112', - '113', - '114', - '115', - '116', - '117', - '118', - '119', - '120', - '121', - '122', - '123', - '124', - '125', - '126', - '127', - '128', - '129', - '130', - '131', - '132', - '133', - '134', - '135', - '136', - '137', - '138', - '139', - '140', - '141', - '142', - '143', - '144', - '145', - '146', - '147', - '148', - '149', - '150', - '151', - '152', - '153', - '154', - '155', - '156', - '157', - '158', - '159', - '160', - '161', - '162', - '163', - '164', - '165', - '166', - '167', - '168', - '169', - '170', - '171' - ] + test("générateur d'appoint (pos 1) : valeur de référence de régression", () => { + const di = callBaseAppoint(1); + // référence de régression (part d'appoint = bch - besoin de base) + expect(di.conso_ch).toBeCloseTo(55.55555555555556, 9); + }); + + test('puissance nulle (pe = 0) : le besoin de base est forfaitairement la moitié du besoin', () => { + const di = callBaseAppoint(0, { pn: 0 }); + // pe = 0 => bch_base_j = besoin_ch_mois * 0.5 sur chaque mois + // référence de régression + expect(di.conso_ch).toBeCloseTo(3.0864197530864197, 9); + }); + + test('faible puissance (dhtj positif) : le besoin de base peut devenir nul (ratioDh <= 0)', () => { + // pn faible => t proche de 14 => xj > 0 => dhtj > 0 et supérieur à dh14j + // => ratioDh <= 0 => bch_base_j forcé à 0 sur les mois concernés + const di = callBaseAppoint(0, { pn: 100 }); + // référence de régression (dhtj > 0, ratioDh <= 0) + expect(di.conso_ch).toBeCloseTo(0, 9); + }); + + test('logement collectif ILPA + valeurs par défaut (rdim, rendement de génération)', () => { + const di = { rg: 1, rg_dep: 1 }; + // pas de rdim => rdim = 1 ; pas de rendement_generation => Ich = 1 + const de = { surface_chauffee: 100 }; + const emBase = { + donnee_entree: { surface_chauffee: 100 }, + donnee_intermediaire: { + i0: 0.9, + rendement_distribution: 0.95, + rendement_regulation: 0.99, + rendement_emission: 0.95 + } }; - const _pos = 0; - - const cfg_ch = 'installation de chauffage simple'; - - const em_list = [ - { - donnee_entree: { - description: '', - reference: 'Emetteur:2024_07_11_09_30_44_722898200737262#1', - surface_chauffee: 22.76, - tv_rendement_emission_id: 1, - tv_rendement_distribution_ch_id: 1, - tv_rendement_regulation_id: 1, - enum_type_emission_distribution_id: '1', - tv_intermittence_id: 138, - reseau_distribution_isole: 0, - enum_equipement_intermittence_id: '4', - enum_type_regulation_id: '2', - enum_periode_installation_emetteur_id: '1', - enum_type_chauffage_id: '1', - enum_temp_distribution_ch_id: '1', - enum_lien_generateur_emetteur_id: '1' - }, - donnee_intermediaire: { - rendement_distribution: 1, - rendement_emission: 0.95, - rendement_regulation: 0.99, - i0: 0.86 - }, - donnee_utilisateur: {} + const genBase = { donnee_intermediaire: { pn: 5000 } }; + const genAppoint = { donnee_intermediaire: { pn: 0 } }; + + conso_ch( + di, + de, + {}, + 0, + 'installation de chauffage collectif avec base + appoint', + [emBase, emBase], + 150, + 100, + 2.5, + 50, + 60, + -9.5, + 1, // ilpa vrai => idx_ilpa = 1 + 1, + 1, + { Janvier: 3000, Février: 2000 }, + 100, + [genBase, genAppoint] + ); + + expect(Number.isFinite(di.conso_ch)).toBe(true); + expect(di.conso_ch).toBeGreaterThanOrEqual(0); + }); + + test('degrés-heures mensuels nuls (dh14j = 0) : mois neutralisé sans division', () => { + const initialFev = tvs.dh14[0].ca1.Février.h1a; + tvs.dh14[0].ca1.Février.h1a = 0; + try { + const di = callBaseAppoint(0, { pn: 100 }); + // le mois à dh14j nul est ignoré (branche dh14j > 0 fausse) + expect(Number.isFinite(di.conso_ch)).toBe(true); + expect(di.conso_ch).toBeGreaterThanOrEqual(0); + } finally { + tvs.dh14[0].ca1.Février.h1a = initialFev; + } + }); + + test('besoin de chauffage nul : la consommation reste indéfinie (aucun calcul)', () => { + const di = { rg: 1, rg_dep: 1 }; + const de = { surface_chauffee: 100, rdim: 1 }; + const emBase = { + donnee_entree: { surface_chauffee: 100 }, + donnee_intermediaire: { + i0: 0.9, + rendement_distribution: 0.95, + rendement_regulation: 0.99, + rendement_emission: 0.95 } - ]; + }; + const genBase = { donnee_intermediaire: { pn: 5000, rendement_generation: 0.9 } }; + + conso_ch( + di, + de, + {}, + 0, + 'installation de chauffage collectif avec base + appoint', + [emBase, emBase], + 150, + 100, + 2.5, + 0, + 0, + -9.5, + 0, + 1, + 1, + { Janvier: 3000, Février: 2000 }, + 100, + [genBase, genBase] + ); - const GV = 150.18558182166174; - const Sh = 22.76; - const hsp = 2.5700000000000003; - const bch = 8001.0714; - const bch_dep = 9714.925476692655; - - conso_ch(di, de, du, _pos, cfg_ch, em_list, GV, Sh, hsp, bch, bch_dep); - - expect(di).toStrictEqual({ - conso_auxiliaire_generation_ch: 0, - conso_auxiliaire_generation_ch_depensier: 0, - conso_ch: 6324.781622192915, - conso_ch_depensier: 7679.569278179351, - rendement_generation: 1, - rg: 1, - rg_dep: 1 - }); + // bch = 0 => la branche de calcul n'est pas exécutée + expect(di.conso_ch).toBeUndefined(); }); }); diff --git a/src/9_emetteur_ch.spec.js b/src/9_emetteur_ch.spec.js new file mode 100644 index 00000000..d1b7c1e4 --- /dev/null +++ b/src/9_emetteur_ch.spec.js @@ -0,0 +1,339 @@ +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, utilState } = vi.hoisted( + () => ({ + getRendementDistributionCh: vi.fn(), + getRendementDistributionChById: vi.fn(), + utilState: { bug: false } + }) +); + +vi.mock('./utils.js', () => ({ + tv: vi.fn(), + get bug_for_bug_compat() { + return utilState.bug; + } +})); + +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(); + utilState.bug = false; +}); + +/** + * 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(); + }); + + test('aucune ligne trouvée pour l’ensemble des rendements : messages d’erreur', () => { + const err = vi.spyOn(console, 'error').mockImplementation(() => {}); + // toutes les tables renvoient null et le store ne fournit rien + vi.mocked(tv).mockReturnValue(null); + getRendementDistributionCh.mockReturnValue(undefined); + getRendementDistributionChById.mockReturnValue(undefined); + + const em_ch = { + donnee_entree: { enum_type_emission_distribution_id: '10' } + }; + calc_emetteur_ch(em_ch, { enum_type_installation_id: '1' }, '2', '3'); + + // distribution + émission + régulation + intermittence => 4 erreurs + expect(err).toHaveBeenCalledTimes(4); + expect(em_ch.donnee_intermediaire).toEqual({}); + err.mockRestore(); + }); + + test("fiche technique de comptage à 'oui' : présence détectée", () => { + stubTv(); + getRendementDistributionCh.mockReturnValue({ + rd: '0.92', + tv_rendement_distribution_ch_id: '44' + }); + + const em_ch = { donnee_entree: { enum_type_emission_distribution_id: '10' } }; + calc_emetteur_ch( + em_ch, + { enum_type_installation_id: '1', ficheTechniqueComptage: { valeur: 'oui' } }, + '2', + '3' + ); + + const matcher = vi.mocked(tv).mock.calls.find((c) => c[0] === 'intermittence')[1]; + expect(matcher.comptage_individuel).toBe('Présence'); + }); + + test('fiche technique de comptage vide : absence de comptage', () => { + stubTv(); + getRendementDistributionCh.mockReturnValue({ + rd: '0.92', + tv_rendement_distribution_ch_id: '44' + }); + + const em_ch = { donnee_entree: { enum_type_emission_distribution_id: '10' } }; + calc_emetteur_ch( + em_ch, + { enum_type_installation_id: '1', ficheTechniqueComptage: { valeur: '' } }, + '2', + '3' + ); + + const matcher = vi.mocked(tv).mock.calls.find((c) => c[0] === 'intermittence')[1]; + expect(matcher.comptage_individuel).toBe('Absence'); + }); + + test('intermittence introuvable : message d’erreur dédié', () => { + const err = vi.spyOn(console, 'error').mockImplementation(() => {}); + 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' }; + // intermittence => null + return null; + }); + getRendementDistributionCh.mockReturnValue({ + rd: '0.92', + tv_rendement_distribution_ch_id: '44' + }); + + const em_ch = { donnee_entree: { enum_type_emission_distribution_id: '10' } }; + calc_emetteur_ch(em_ch, { enum_type_installation_id: '1' }, '2', '3'); + + expect(err).toHaveBeenCalled(); + expect(em_ch.donnee_intermediaire.i0).toBeUndefined(); + err.mockRestore(); + }); +}); + +/** + * Mode "bug for bug" : en l'absence de fiche technique, la présence d'un + * comptage individuel est déduite de la ligne d'intermittence de référence. + */ +describe('calc_emetteur_ch - déduction du comptage en mode "bug for bug"', () => { + /** Pilote `tv` avec une ligne d'intermittence paramétrable. */ + function stubTvIntermittence(comptageRow) { + vi.mocked(tv).mockImplementation((table, matcher) => { + 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') { + // 1er appel : matcher basé sur tv_intermittence_id (déduction du comptage) + if (matcher.tv_intermittence_id !== undefined && !matcher.enum_type_installation_id) { + return comptageRow; + } + return { i0: '0.85', tv_intermittence_id: '33' }; + } + return null; + }); + getRendementDistributionCh.mockReturnValue({ + rd: '0.92', + tv_rendement_distribution_ch_id: '44' + }); + } + + const emetteur = () => ({ + donnee_entree: { enum_type_emission_distribution_id: '10', tv_intermittence_id: 5 } + }); + + test('ligne de référence "Présence" => comptage individuel présent', () => { + utilState.bug = true; + stubTvIntermittence({ comptage_individuel: 'Présence d’un comptage' }); + + const em_ch = emetteur(); + calc_emetteur_ch(em_ch, { enum_type_installation_id: '1' }, '2', '3'); + + const matcher = vi + .mocked(tv) + .mock.calls.find((c) => c[0] === 'intermittence' && c[1].enum_type_installation_id)[1]; + expect(matcher.comptage_individuel).toBe('Présence'); + }); + + test('ligne de référence sans "Présence" => absence de comptage', () => { + utilState.bug = true; + stubTvIntermittence({ comptage_individuel: 'Absence de comptage' }); + + const em_ch = emetteur(); + calc_emetteur_ch(em_ch, { enum_type_installation_id: '1' }, '2', '3'); + + const matcher = vi + .mocked(tv) + .mock.calls.find((c) => c[0] === 'intermittence' && c[1].enum_type_installation_id)[1]; + expect(matcher.comptage_individuel).toBe('Absence'); + }); + + test('ligne de référence sans colonne comptage => absence de comptage', () => { + utilState.bug = true; + stubTvIntermittence({ i0: '0.5' }); + + const em_ch = emetteur(); + calc_emetteur_ch(em_ch, { enum_type_installation_id: '1' }, '2', '3'); + + const matcher = vi + .mocked(tv) + .mock.calls.find((c) => c[0] === 'intermittence' && c[1].enum_type_installation_id)[1]; + expect(matcher.comptage_individuel).toBe('Absence'); + }); + + test('aucune ligne de référence trouvée => absence de comptage', () => { + utilState.bug = true; + stubTvIntermittence(null); + + const em_ch = emetteur(); + calc_emetteur_ch(em_ch, { enum_type_installation_id: '1' }, '2', '3'); + + const matcher = vi + .mocked(tv) + .mock.calls.find((c) => c[0] === 'intermittence' && c[1].enum_type_installation_id)[1]; + expect(matcher.comptage_individuel).toBe('Absence'); + }); +}); diff --git a/src/9_generateur_ch.spec.js b/src/9_generateur_ch.spec.js index 4024bbcf..fda6d385 100644 --- a/src/9_generateur_ch.spec.js +++ b/src/9_generateur_ch.spec.js @@ -1,94 +1,480 @@ -import { calc_generateur_ch } from './9_generateur_ch.js'; -import { describe, expect, test } from 'vitest'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; -describe('Recherche de bugs dans le calcul de la consommation des generateurs de chauffage', () => { - test('calcul de la consommation des generateurs de chauffage pour 2475E2510509B', () => { - const dpe = { - logement: { - enveloppe: { inertie: { enum_classe_inertie_id: '5' }, mur_collection: { mur: [] } } - } - }; - const gen_ch = { +/** + * Dépendances mockées pour isoler `9_generateur_ch.js` : + * - `enums` : libellés des types de générateurs (ch / ecs) ; + * - `utils` : `requestInput` / `requestInputID` (accès aux données d'entrée), + * `tv` (tables de valeurs) et `tvColumnIDs` (listes d'ids par colonne) ; + * - `15_conso_aux` : consommations d'auxiliaires (génération / distribution) ; + * - `9_conso_ch` : `conso_ch` (délégation finale) ; + * - `13.2_generateur_combustion_ch` / `12.4_pac` / `13.2_generateur_combustion` : + * calculs spécialisés (combustion / PAC) pilotés par des espions. + * + * On vérifie la logique d'aiguillage propre au module (type de générateur, + * récupération d'énergie, auxiliaires, parois anciennes) sans dépendre des + * données réelles des tables. + */ +vi.mock('./enums.js', () => ({ + default: { + type_generateur_ch: { + 1: 'chaudière gaz', + 2: 'générateur à air chaud', + 5: 'chaudière fioul', + 10: 'pac air/eau' + }, + type_generateur_ecs: { + 1: 'chaudière gaz' + } + } +})); + +vi.mock('./utils.js', () => ({ + requestInput: vi.fn(), + requestInputID: vi.fn(), + tv: vi.fn(), + tvColumnIDs: vi.fn() +})); + +vi.mock('./15_conso_aux.js', () => ({ + conso_aux_distribution_ch: vi.fn(), + conso_aux_gen: vi.fn() +})); + +vi.mock('./9_conso_ch.js', () => ({ + conso_ch: vi.fn() +})); + +vi.mock('./13.2_generateur_combustion_ch.js', () => ({ + calc_generateur_combustion_ch: vi.fn() +})); + +vi.mock('./12.4_pac.js', () => ({ + scopOrCop: vi.fn() +})); + +vi.mock('./13.2_generateur_combustion.js', () => ({ + updateGenerateurCombustion: vi.fn() +})); + +const { + calc_Qrec_gen_j, + type_generateur_ch, + checkForGeneratorType, + calc_generateur_ch, + hasConsoForAuxDistribution +} = await import('./9_generateur_ch.js'); +const { requestInput, requestInputID, tv, tvColumnIDs } = await import('./utils.js'); +const { conso_aux_distribution_ch, conso_aux_gen } = await import('./15_conso_aux.js'); +const { conso_ch } = await import('./9_conso_ch.js'); +const { calc_generateur_combustion_ch } = await import('./13.2_generateur_combustion_ch.js'); +const { scopOrCop } = await import('./12.4_pac.js'); +const { updateGenerateurCombustion } = await import('./13.2_generateur_combustion.js'); + +beforeEach(() => { + vi.mocked(requestInput).mockReset(); + vi.mocked(requestInputID).mockReset(); + vi.mocked(tv).mockReset(); + vi.mocked(tvColumnIDs).mockReset(); + vi.mocked(conso_aux_distribution_ch).mockReset(); + vi.mocked(conso_aux_gen).mockReset(); + vi.mocked(conso_ch).mockReset(); + vi.mocked(calc_generateur_combustion_ch).mockReset(); + vi.mocked(scopOrCop).mockReset(); + vi.mocked(updateGenerateurCombustion).mockReset(); +}); + +/** + * 9.1.1 Pertes récupérées de génération pour le chauffage (Qrec_gen) + * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §9.1.1 + */ +describe('calc_Qrec_gen_j - pertes de génération récupérées', () => { + /** Générateur minimal en volume chauffé, non "air chaud". */ + function generateur(overridesDe = {}, di = { pn: 10000, qp0: 100 }) { + return { donnee_entree: { - description: 'Electrique - Convecteur électrique NFC, NF** et NF***', - reference: 'Generateur:2024_07_11_09_30_44_722898200737262#1', - reference_generateur_mixte: '', - ref_produit_generateur_ch: 'Sans Objet', - enum_type_generateur_ch_id: '98', - enum_usage_generateur_id: '1', - enum_type_energie_id: '1', position_volume_chauffe: 1, - tv_rendement_generation_id: 29, - identifiant_reseau_chaleur: '', - enum_methode_saisie_carac_sys_id: '1', - enum_lien_generateur_emetteur_id: '1' - } + enum_type_generateur_ch_id: '1', + presence_ventouse: 0, + ...overridesDe + }, + donnee_intermediaire: di }; - const _pos = 0; - const em_ch = [ - { - donnee_entree: { - description: '', - reference: 'Emetteur:2024_07_11_09_30_44_722898200737262#1', - surface_chauffee: 22.76, - tv_rendement_emission_id: 1, - tv_rendement_distribution_ch_id: 1, - tv_rendement_regulation_id: 1, - enum_type_emission_distribution_id: '1', - tv_intermittence_id: 138, - reseau_distribution_isole: 0, - enum_equipement_intermittence_id: '4', - enum_type_regulation_id: '2', - enum_periode_installation_emetteur_id: '1', - enum_type_chauffage_id: '1', - enum_temp_distribution_ch_id: '1', - enum_lien_generateur_emetteur_id: '1' - }, - donnee_intermediaire: { - rendement_distribution: 1, - rendement_emission: 0.95, - rendement_regulation: 0.99, - i0: 0.86 - }, - donnee_utilisateur: {} + } + + test('générateur hors volume chauffé : aucune récupération', () => { + const gen = generateur({ position_volume_chauffe: 0 }); + expect(calc_Qrec_gen_j(gen, 50, 1000)).toBe(0); + }); + + test('générateur à air chaud : aucune récupération', () => { + const gen = generateur({ enum_type_generateur_ch_id: '2' }); + expect(calc_Qrec_gen_j(gen, 50, 1000)).toBe(0); + }); + + test('usage chauffage (sans ventouse, Cper = 0.5) : Dperj plafonné aux pertes de chauffage', () => { + vi.mocked(requestInput).mockReturnValue('chauffage'); + const gen = generateur(); + // 0.48 * 0.5 * 100 * min(50, 1.3*1000/(0.3*10000)) = 24 * 0.4333.. = 10.4 + expect(calc_Qrec_gen_j(gen, 50, 1000)).toBeCloseTo(10.4, 9); + }); + + test('présence de ventouse : Cper = 0.75 augmente la récupération', () => { + vi.mocked(requestInput).mockReturnValue('chauffage'); + const gen = generateur({ presence_ventouse: 1 }); + // 0.48 * 0.75 * 100 * 0.4333.. = 15.6 + expect(calc_Qrec_gen_j(gen, 50, 1000)).toBeCloseTo(15.6, 9); + }); + + test('usage ecs : pertes proportionnelles au nombre d’heures de référence', () => { + vi.mocked(requestInput).mockReturnValue('ecs'); + const gen = generateur(); + // 0.48 * 0.5 * 100 * (50*1790/8760) + expect(calc_Qrec_gen_j(gen, 50, 1000)).toBeCloseTo(245.20547945205482, 9); + }); + + test('usage chauffage + ecs : cumul plafonné au nombre d’heures de référence', () => { + vi.mocked(requestInput).mockReturnValue('chauffage + ecs'); + const gen = generateur(); + // min(50, 0.4333.. + 10.2168..) => 10.6502.. + expect(calc_Qrec_gen_j(gen, 50, 1000)).toBeCloseTo(255.6054794520548, 9); + }); + + test('usage inconnu : Dperj indéfini => récupération nulle (|| 0)', () => { + vi.mocked(requestInput).mockReturnValue('autre'); + const gen = generateur(); + expect(calc_Qrec_gen_j(gen, 50, 1000)).toBe(0); + }); +}); + +/** + * Détermination du type de générateur exploitable pour un usage donné. + */ +describe('type_generateur_ch - sélection du type selon l’usage', () => { + test('usage chauffage : renvoie directement le type saisi', () => { + vi.mocked(requestInputID).mockReturnValue('5'); + expect(type_generateur_ch({}, {}, {}, 'chauffage')).toBe('5'); + expect(requestInputID).toHaveBeenCalledWith({}, {}, 'type_generateur_ch'); + }); + + test('usage chauffage + ecs : restreint aux types communs chauffage/ECS', () => { + vi.mocked(requestInputID).mockReturnValue('1'); + const res = type_generateur_ch({}, {}, {}, 'chauffage + ecs'); + expect(res).toBe('1'); + // seul le type '1' (chaudière gaz) est commun aux deux enums + expect(vi.mocked(requestInputID).mock.calls[0][3]).toEqual(['1']); + }); + + test('usage non géré : avertissement et retour indéfini', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + expect(type_generateur_ch({}, {}, {}, 'refroidissement')).toBeUndefined(); + expect(warn).toHaveBeenCalled(); + warn.mockRestore(); + }); +}); + +/** + * checkForGeneratorType : classification combustion / PAC, cas particulier 119. + */ +describe('checkForGeneratorType - classification du générateur', () => { + beforeEach(() => { + vi.mocked(tvColumnIDs).mockImplementation((file) => + file === 'generateur_combustion' ? ['5', '119'] : ['10'] + ); + vi.mocked(requestInput).mockReturnValue('chauffage'); + }); + + test('générateur à combustion', () => { + vi.mocked(requestInputID).mockReturnValue('5'); + const de = {}; + const du = {}; + checkForGeneratorType({}, de, {}, du); + expect(du.isCombustionGenerator).toBe(true); + expect(du.isPacGenerator).toBe(false); + expect(updateGenerateurCombustion).toHaveBeenCalledWith({}, de, 'ch'); + }); + + test('pompe à chaleur', () => { + vi.mocked(requestInputID).mockReturnValue('10'); + const du = {}; + checkForGeneratorType({}, {}, {}, du); + expect(du.isPacGenerator).toBe(true); + expect(du.isCombustionGenerator).toBe(false); + }); + + test('générateur ni combustion ni PAC', () => { + vi.mocked(requestInputID).mockReturnValue('99'); + const du = {}; + checkForGeneratorType({}, {}, {}, du); + expect(du.isPacGenerator).toBe(false); + expect(du.isCombustionGenerator).toBe(false); + }); + + test('cas 119 avec tv_generateur_combustion_id : premier type de combustion adopté', () => { + vi.mocked(requestInputID).mockReturnValue('119'); + vi.mocked(tv).mockReturnValue({ enum_type_generateur_ch_id: '7|8' }); + const de = { tv_generateur_combustion_id: 42 }; + const du = {}; + checkForGeneratorType({}, de, {}, du); + expect(de.enum_type_generateur_ch_id).toBe('7'); + expect(du.isCombustionGenerator).toBe(true); + expect(du.isPacGenerator).toBe(false); + }); + + test('cas 119 avec tv_generateur_combustion_id mais ligne sans type : combustion sans réécriture', () => { + vi.mocked(requestInputID).mockReturnValue('119'); + vi.mocked(tv).mockReturnValue({ enum_type_generateur_ch_id: undefined }); + const de = { tv_generateur_combustion_id: 42 }; + const du = {}; + checkForGeneratorType({}, de, {}, du); + expect(de.enum_type_generateur_ch_id).toBeUndefined(); + expect(du.isCombustionGenerator).toBe(true); + }); + + test('cas 119 avec tv_generateur_combustion_id mais aucune ligne trouvée', () => { + vi.mocked(requestInputID).mockReturnValue('119'); + vi.mocked(tv).mockReturnValue(null); + const de = { tv_generateur_combustion_id: 42 }; + const du = {}; + checkForGeneratorType({}, de, {}, du); + expect(du.isCombustionGenerator).toBe(true); + }); + + test('cas 119 avec tv_scop_id : classé comme PAC', () => { + vi.mocked(requestInputID).mockReturnValue('119'); + const de = { tv_scop_id: 7 }; + const du = {}; + checkForGeneratorType({}, de, {}, du); + expect(du.isPacGenerator).toBe(true); + expect(du.isCombustionGenerator).toBe(false); + }); + + test('cas 119 par défaut : rendement de génération, premier type adopté', () => { + vi.mocked(requestInputID).mockReturnValue('119'); + vi.mocked(tv).mockReturnValue({ enum_type_generateur_ch_id: '9|10' }); + const de = { tv_rendement_generation_id: 3 }; + const du = {}; + checkForGeneratorType({}, de, {}, du); + expect(de.enum_type_generateur_ch_id).toBe('9'); + expect(du.isPacGenerator).toBe(false); + expect(du.isCombustionGenerator).toBe(false); + }); + + test('cas 119 par défaut sans ligne de rendement : aucune réécriture', () => { + vi.mocked(requestInputID).mockReturnValue('119'); + vi.mocked(tv).mockReturnValue(null); + const de = { tv_rendement_generation_id: 3, enum_type_generateur_ch_id: '119' }; + const du = {}; + checkForGeneratorType({}, de, {}, du); + expect(de.enum_type_generateur_ch_id).toBe('119'); + }); +}); + +/** + * 15 Consommations d'auxiliaires de distribution : générateurs concernés. + * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §15 + */ +describe('hasConsoForAuxDistribution - éligibilité aux auxiliaires de distribution', () => { + test('générateurs éligibles (chacune des plages)', () => { + expect(hasConsoForAuxDistribution(110)).toBe(true); // >= 106 + expect(hasConsoForAuxDistribution(60)).toBe(true); // 55..97 + expect(hasConsoForAuxDistribution(50)).toBe(true); // 48..52 + expect(hasConsoForAuxDistribution(10)).toBe(true); // 4..19 + }); + + test('générateurs exclus (PAC air/air, poêles, radiateurs, effet joule)', () => { + expect(hasConsoForAuxDistribution(3)).toBe(false); + expect(hasConsoForAuxDistribution(20)).toBe(false); + expect(hasConsoForAuxDistribution(53)).toBe(false); + expect(hasConsoForAuxDistribution(100)).toBe(false); + }); +}); + +/** + * calc_generateur_ch : aiguillage du rendement puis délégations. + */ +describe('calc_generateur_ch - orchestration du calcul générateur', () => { + /** dpe avec un mur non ancien, inertie légère => parois anciennes = false. */ + function dpeSimple() { + return { + logement: { + enveloppe: { + inertie: { enum_classe_inertie_id: '3' }, + mur_collection: { + mur: [ + { + donnee_intermediaire: { b: 1 }, + donnee_entree: { enum_materiaux_structure_mur_id: '1' } + } + ] + } + } } - ]; - const cfg_ch = 'installation de chauffage simple'; - const bch = 8001.0714; - const bch_dep = 9714.925476692655; - const GV = 150.18558182166174; - const Sh = 22.76; - const Sc = 22.76; - const hsp = 2.5700000000000003; - const ca_id = 1; - const zc_id = 1; - const ac = 1; + }; + } + function generateur(de = {}, du = {}, di = {}) { + return { + donnee_entree: { enum_type_generateur_ch_id: '1', ...de }, + donnee_utilisateur: du, + donnee_intermediaire: di + }; + } + + const emetteur = (lien) => ({ + donnee_entree: { + enum_lien_generateur_emetteur_id: lien, + enum_type_emission_distribution_id: 'ed1' + } + }); + + function appel(gen, em_ch, dpe = dpeSimple()) { calc_generateur_ch( dpe, - gen_ch, - _pos, + gen, + 0, em_ch, - cfg_ch, - bch, - bch_dep, - GV, - Sh, - Sc, - hsp, - ca_id, - zc_id, - ac + 'installation de chauffage simple', + 1000, + 2000, + 150, + 100, + 2.5, + 1, + 1, + 0, + -9.5, + {}, + 100, + [gen] + ); + } + + test('PAC avec un unique émetteur : scopOrCop appelé sur cet émetteur', () => { + const gen = generateur({}, { isPacGenerator: true }); + appel(gen, [emetteur('1')]); + expect(scopOrCop).toHaveBeenCalledWith( + gen.donnee_intermediaire, + gen.donnee_entree, + gen.donnee_utilisateur, + 1, + 'ed1', + 'ch' ); + // délégations finales + expect(conso_aux_gen).toHaveBeenCalled(); + expect(conso_ch).toHaveBeenCalled(); + }); + + test('PAC avec plusieurs émetteurs : sélection par lien générateur/émetteur', () => { + vi.mocked(requestInputID).mockReturnValue('2'); + const gen = generateur({}, { isPacGenerator: true }); + appel(gen, [emetteur('1'), emetteur('2')]); + // l'émetteur de lien '2' est retenu (enum_type_emission_distribution_id = 'ed1') + expect(scopOrCop).toHaveBeenCalled(); + expect(scopOrCop.mock.calls[0][4]).toBe('ed1'); + }); + + test('PAC sans émetteur correspondant : repli sur scop/cop saisis', () => { + const err = vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.mocked(requestInputID).mockReturnValue('inconnu'); + const gen = generateur({ description: 'PAC' }, { isPacGenerator: true }, { scop: 3.2 }); + appel(gen, [emetteur('1'), emetteur('2')]); + expect(scopOrCop).not.toHaveBeenCalled(); + expect(gen.donnee_intermediaire.rg).toBe(3.2); + expect(gen.donnee_intermediaire.rg_dep).toBe(3.2); + expect(err).toHaveBeenCalled(); + err.mockRestore(); + }); + + test('PAC sans émetteur ni scop : repli sur le cop saisi', () => { + const err = vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.mocked(requestInputID).mockReturnValue('inconnu'); + const gen = generateur({ description: 'PAC' }, { isPacGenerator: true }, { cop: 2.7 }); + appel(gen, [emetteur('1'), emetteur('2')]); + expect(gen.donnee_intermediaire.rg).toBe(2.7); + expect(gen.donnee_intermediaire.rg_dep).toBe(2.7); + err.mockRestore(); + }); + + test('générateur à combustion : délégué à calc_generateur_combustion_ch', () => { + const gen = generateur({}, { isCombustionGenerator: true }); + appel(gen, [emetteur('1')]); + expect(calc_generateur_combustion_ch).toHaveBeenCalled(); + }); + + test('générateur standard : rendement de génération lu en table', () => { + vi.mocked(requestInputID).mockReturnValue('1'); + vi.mocked(tv).mockReturnValue({ rg: '0.85', tv_rendement_generation_id: '77' }); + const gen = generateur(); + appel(gen, [emetteur('1')]); + expect(gen.donnee_intermediaire.rendement_generation).toBe(0.85); + expect(gen.donnee_intermediaire.rg).toBe(0.85); + expect(gen.donnee_entree.tv_rendement_generation_id).toBe(77); + }); + + test('générateur standard sans ligne de rendement : message d’erreur', () => { + const err = vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.mocked(requestInputID).mockReturnValue('1'); + vi.mocked(tv).mockReturnValue(null); + const gen = generateur(); + appel(gen, [emetteur('1')]); + expect(err).toHaveBeenCalled(); + err.mockRestore(); + }); + + test('générateur éligible aux auxiliaires de distribution : conso_aux_distribution_ch appelé', () => { + vi.mocked(requestInputID).mockReturnValue('1'); + vi.mocked(tv).mockReturnValue({ rg: '0.9', tv_rendement_generation_id: '1' }); + const gen = generateur({ enum_type_generateur_ch_id: '110' }); + appel(gen, [emetteur('1')]); + expect(conso_aux_distribution_ch).toHaveBeenCalled(); + }); + + test('générateur non éligible aux auxiliaires de distribution : conso_aux_distribution_ch ignoré', () => { + vi.mocked(requestInputID).mockReturnValue('1'); + vi.mocked(tv).mockReturnValue({ rg: '0.9', tv_rendement_generation_id: '1' }); + const gen = generateur({ enum_type_generateur_ch_id: '100' }); + appel(gen, [emetteur('1')]); + expect(conso_aux_distribution_ch).not.toHaveBeenCalled(); + }); + + test('bâtiment à parois anciennes et inertie lourde transmis à conso_ch', () => { + vi.mocked(requestInputID).mockReturnValue('1'); + vi.mocked(tv).mockReturnValue({ rg: '0.9', tv_rendement_generation_id: '1' }); + const gen = generateur(); + const dpe = { + logement: { + enveloppe: { + inertie: { enum_classe_inertie_id: '1' }, // inertie lourde + mur_collection: { + mur: [ + { + donnee_intermediaire: { b: 1 }, + donnee_entree: { enum_materiaux_structure_mur_id: '2' } // matériau ancien + }, + { + // mur avec b = 0 => ignoré dans le décompte + donnee_intermediaire: { b: 0 }, + donnee_entree: { enum_materiaux_structure_mur_id: '1' } + } + ] + } + } + } + }; + appel(gen, [emetteur('1')], dpe); + // paroi_ancienne (13e argument) transmis à conso_ch + expect(vi.mocked(conso_ch).mock.calls[0][12]).toBe(true); + }); - expect(gen_ch.donnee_intermediaire).toStrictEqual({ - conso_auxiliaire_generation_ch: 0, - conso_auxiliaire_generation_ch_depensier: 0, - conso_ch: 6324.781622192915, // 7315,978 trouvé par Olivier - conso_ch_depensier: 7679.569278179351, - rendement_generation: 1, - rg: 1, - rg_dep: 1 - }); + test('générateur sans données utilisateur/intermédiaire initiales : valeurs par défaut', () => { + vi.mocked(requestInputID).mockReturnValue('1'); + vi.mocked(tv).mockReturnValue({ rg: '0.9', tv_rendement_generation_id: '1' }); + const gen = { + donnee_entree: { enum_type_generateur_ch_id: '1' } + }; + appel(gen, [emetteur('1')]); + expect(gen.donnee_intermediaire).toBeDefined(); + expect(gen.donnee_utilisateur).toBeDefined(); }); }); diff --git a/src/apport_et_besoin.spec.js b/src/apport_et_besoin.spec.js new file mode 100644 index 00000000..841bd584 --- /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/conso.js b/src/conso.js index 39b3856f..5e096dab 100644 --- a/src/conso.js +++ b/src/conso.js @@ -98,7 +98,7 @@ const coef_cout = { * @param destination {'ch' | 'ecs' | 'éclairage'} * @return {string} la clef à utiliser pour récupérer le coefficient depuis la table `coef_ges` */ -function getCoefKey(type_energie, destination) { +export function getCoefKey(type_energie, destination) { if (type_energie === 'électricité') { if (!['ch', 'ecs', 'éclairage'].includes(destination)) { console.error(`Type d'électricité inconnu: ${destination}`); diff --git a/src/conso.spec.js b/src/conso.spec.js index 5451b823..3bbac5fb 100644 --- a/src/conso.spec.js +++ b/src/conso.spec.js @@ -1,91 +1,480 @@ -import calc_conso from './conso.js'; -import { describe, expect, test } from 'vitest'; - -describe('Recherche de bugs dans le calcul de la consommation', () => { - test('calcul de la consommation de chauffage pour 2475E2510509B', () => { - const Sh = 22.76; // Surface habitable - const zc_id = 1; // Zone climatique - const ca_id = 1; // Classe d'altitude - const vt = []; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +/** + * Dépendances mockées pour isoler `conso.js` : + * - `enums` : libellés de type d'énergie / classe d'altitude / zone climatique ; + * - `16_conso_eclairage` : consommation d'éclairage forcée à 1 kWh/m² (=> conso = Sh) ; + * - `tvs` : seuils de classe DPE / GES contrôlés ; + * - `tv` (utils) : lecture des réseaux de chaleur, retour maîtrisé. + * Les tables internes du module (coef_ges, coef_cout, coef_ep...) ne sont pas mockées : + * elles font partie du code testé. + */ +vi.mock('./enums.js', () => ({ + default: { + type_energie: { + 1: 'électricité', + 2: 'gaz naturel', + 3: 'réseau de chauffage urbain', + 4: 'inconnu' + }, + classe_altitude: { 1: 'inférieur à 400m', 2: 'supérieur à 800m' }, + zone_climatique: { 1: 'h1a', 2: 'h1b' } + } +})); + +vi.mock('./16_conso_eclairage.js', () => ({ default: vi.fn(() => 1) })); + +vi.mock('./tv.js', () => ({ + default: { + dpe_class_limit: { + 'inférieur à 400m': { 100: { A: 10, B: 20, C: 30, D: 40, E: 300, F: 400 } }, + 'supérieur à 800m': {} + }, + ges_class_limit: { + 'inférieur à 400m': { 100: { A: 1, B: 2, C: 3, D: 4, E: 60, F: 90 } }, + 'supérieur à 800m': {} + } + } +})); + +vi.mock('./utils.js', () => ({ + tv: vi.fn((table, matcher) => { + // Réseau de chaleur "R1" connu, "R2" inconnu + if (matcher?.identifiant_reseau === 'R1') return { contenu_co2_acv: 0.15 }; + return null; + }) +})); + +const { + default: calc_conso, + classe_bilan_dpe, + classe_emission_ges, + getCoefKey, + coef_ep +} = await import('./conso.js'); + +/** + * Classe énergétique (bilan DPE) + * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf + */ +describe('classe_bilan_dpe - seuils de classe énergétique', () => { + test('consommation nulle (null) : aucune classe', () => { + expect(classe_bilan_dpe(null, 1, 1, 100)).toBeNull(); + }); + + // ca_id=1 => seuils personnalisés {A:10,B:20,C:30,D:40,E:300,F:400} + test.each([ + [5, 'A'], + [15, 'B'], + [25, 'C'], + [35, 'D'], + [250, 'E'], + [350, 'F'], + [450, 'G'] + ])('seuils personnalisés : conso %s => classe %s', (conso, classe) => { + expect(classe_bilan_dpe(conso, 1, 1, 100)).toBe(classe); + }); + + // ca_id=2 => seuils absents => valeurs par défaut + test('valeurs par défaut A-D quand la table ne fournit pas de seuil', () => { + expect(classe_bilan_dpe(50, 1, 2, 100)).toBe('A'); // < 70 + expect(classe_bilan_dpe(100, 1, 2, 100)).toBe('B'); // < 110 + }); + + test('zone climatique standard : seuils E/F par défaut 330/420', () => { + // zc_id=1 (h1a) non concernée par le cas montagne => 330/420 + expect(classe_bilan_dpe(300, 1, 2, 100)).toBe('E'); + expect(classe_bilan_dpe(400, 1, 2, 100)).toBe('F'); + expect(classe_bilan_dpe(500, 1, 2, 100)).toBe('G'); + }); + + test('zone montagne (h1b, > 800m) : seuils E/F relevés à 390/500', () => { + // zc_id=2 (h1b) + ca 'supérieur à 800m' => 390/500 + expect(classe_bilan_dpe(350, 2, 2, 100)).toBe('E'); + expect(classe_bilan_dpe(450, 2, 2, 100)).toBe('F'); + expect(classe_bilan_dpe(600, 2, 2, 100)).toBe('G'); + }); +}); + +/** + * Classe climat (émissions GES) + * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf + */ +describe('classe_emission_ges - seuils de classe climat', () => { + test('émission nulle (null) : aucune classe', () => { + expect(classe_emission_ges(null, 1, 1, 100)).toBeNull(); + }); + + test.each([ + [0.5, 'A'], + [1.5, 'B'], + [2.5, 'C'], + [3.5, 'D'], + [55, 'E'], + [70, 'F'], + [95, 'G'] + ])('seuils personnalisés : émission %s => classe %s', (emission, classe) => { + expect(classe_emission_ges(emission, 1, 1, 100)).toBe(classe); + }); + + test('valeurs par défaut A-D quand la table ne fournit pas de seuil', () => { + expect(classe_emission_ges(3, 1, 2, 100)).toBe('A'); // < 6 + expect(classe_emission_ges(10, 1, 2, 100)).toBe('B'); // < 11 + }); + + test('zone climatique standard : seuils E/F par défaut 70/100', () => { + expect(classe_emission_ges(60, 1, 2, 100)).toBe('E'); + expect(classe_emission_ges(80, 1, 2, 100)).toBe('F'); + expect(classe_emission_ges(120, 1, 2, 100)).toBe('G'); + }); + + test('zone montagne (h1b, > 800m) : seuils E/F relevés à 80/110', () => { + expect(classe_emission_ges(70, 2, 2, 100)).toBe('E'); + expect(classe_emission_ges(90, 2, 2, 100)).toBe('F'); + expect(classe_emission_ges(120, 2, 2, 100)).toBe('G'); + }); +}); + +/** + * Construction de la clef de la table `coef_ges` pour l'électricité (dépend de l'usage). + */ +describe('getCoefKey - clef du coefficient GES', () => { + test('énergie non électrique : le libellé est renvoyé tel quel', () => { + expect(getCoefKey('gaz naturel', 'ch')).toBe('gaz naturel'); + }); + + test("électricité : suffixe d'usage ajouté", () => { + expect(getCoefKey('électricité', 'ch')).toBe('électricité ch'); + expect(getCoefKey('électricité', 'ecs')).toBe('électricité ecs'); + }); + + test('électricité avec usage inconnu : erreur signalée mais clef construite', () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + expect(getCoefKey('électricité', 'xxx')).toBe('électricité xxx'); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('xxx')); + errorSpy.mockRestore(); + }); +}); + +/** Fabrique un générateur de chauffage. */ +function genCh(energieId, di, deExtra = {}) { + return { + donnee_entree: { enum_type_energie_id: energieId, ...deExtra }, + donnee_intermediaire: di + }; +} + +/** Fabrique une installation de chauffage. */ +function installCh(deExtra, gens) { + return { + donnee_entree: { + enum_type_installation_id: '1', + enum_methode_calcul_conso_id: '1', + ...deExtra + }, + generateur_chauffage_collection: { generateur_chauffage: gens } + }; +} + +/** Fabrique un générateur ECS. */ +function genEcs(energieId, di, deExtra = {}) { + return { + donnee_entree: { enum_type_energie_id: energieId, ...deExtra }, + donnee_intermediaire: di + }; +} + +/** Fabrique une installation ECS. */ +function installEcs(deExtra, gens) { + return { + donnee_entree: { ...deExtra }, + generateur_ecs_collection: { generateur_ecs: gens } + }; +} + +const DATE_DPE = '2024-01-01'; + +describe('calc_conso - agrégation des consommations', () => { + let errorSpy; + + beforeEach(() => { + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + errorSpy.mockRestore(); + }); + + test('chauffage électrique individuel : EF, EP (×1,9), GES et coût déterministes', () => { const ch = [ - { - donnee_entree: { - description: - 'Convecteur électrique NFC, NF** et NF*** avec programmateur pièce par pièce (système individuel)', - reference: '2024_07_11_09_30_44_722898200737262', - surface_chauffee: 22.76, - rdim: 1, - nombre_niveau_installation_ch: 1, - enum_cfg_installation_ch_id: '1', - enum_type_installation_id: '1', - enum_methode_calcul_conso_id: '1' + installCh({ cle_repartition_ch: 1 }, [ + genCh('1', { conso_ch: 1000, conso_ch_depensier: 1200 }) + ]) + ]; + + const res = calc_conso(100, 1, 1, [], ch, [], [], 1, 1, DATE_DPE, coef_ep); + + // EF : passe-plat (coefficient nul) + expect(res.ef_conso.conso_ch).toBe(1000); + // EP : coefficient électricité 1,9 + expect(res.ep_conso.ep_conso_ch).toBeCloseTo(1900, 9); + // GES : coef_ges['électricité ch'] = 0,079 + expect(res.emission_ges.emission_ges_ch).toBeCloseTo(79, 9); + // Coût : cout_electricite(1000) = 149 + 0,14066 * 1000 + expect(res.cout.cout_ch).toBeCloseTo(149 + 0.14066 * 1000, 9); + // Classes calculées et injectées + expect(res.ep_conso.classe_bilan_dpe).toBeDefined(); + expect(res.emission_ges.classe_emission_ges).toBeDefined(); + // Éclairage = calc_conso_eclairage(zc) * Sh = 1 * 100 (passe-plat EF) + expect(res.ef_conso.conso_eclairage).toBe(100); + }); + + test('installation collective (méthode 2) : clé de répartition appliquée à conso et distribution', () => { + const ch = [ + installCh( + { + cle_repartition_ch: 2, + enum_type_installation_id: '2', + enum_methode_calcul_conso_id: '2' }, - emetteur_chauffage_collection: { - emetteur_chauffage: [ - { - donnee_entree: { - description: '', - reference: 'Emetteur:2024_07_11_09_30_44_722898200737262#1', - surface_chauffee: 22.76, - tv_rendement_emission_id: 1, - tv_rendement_distribution_ch_id: 1, - tv_rendement_regulation_id: 1, - enum_type_emission_distribution_id: '1', - tv_intermittence_id: 138, - reseau_distribution_isole: 0, - enum_equipement_intermittence_id: '4', - enum_type_regulation_id: '2', - enum_periode_installation_emetteur_id: '1', - enum_type_chauffage_id: '1', - enum_temp_distribution_ch_id: '1', - enum_lien_generateur_emetteur_id: '1' - }, - donnee_intermediaire: { - rendement_distribution: 1, - rendement_emission: 0.95, - rendement_regulation: 0.99, - i0: 0.86 - } - } - ] + [ + genCh('1', { + conso_ch: 1000, + conso_ch_depensier: 1200, + conso_auxiliaire_distribution_ch: 100 + }) + ] + ) + ]; + + const res = calc_conso(100, 1, 1, [], ch, [], [], 1, 1, DATE_DPE, coef_ep); + + // conso_ch = 1000 * clé (2) + expect(res.ef_conso.conso_ch).toBe(2000); + // distribution = 100 * clé (2) (méthode 2 + clé présente) + expect(res.ef_conso.conso_auxiliaire_distribution_ch).toBe(200); + }); + + test('installation avec clé absente (non collective) : répartition = prorata chauffage', () => { + const ch = [ + installCh( + { + cle_repartition_ch: 0, + enum_type_installation_id: '1', + enum_methode_calcul_conso_id: '3' }, - generateur_chauffage_collection: { - generateur_chauffage: [ - { - donnee_entree: { - description: 'Electrique - Convecteur électrique NFC, NF** et NF***', - reference: 'Generateur:2024_07_11_09_30_44_722898200737262#1', - reference_generateur_mixte: '', - ref_produit_generateur_ch: 'Sans Objet', - enum_type_generateur_ch_id: '98', - enum_usage_generateur_id: '1', - enum_type_energie_id: '1', - position_volume_chauffe: 1, - tv_rendement_generation_id: 29, - identifiant_reseau_chaleur: '', - enum_methode_saisie_carac_sys_id: '1', - enum_lien_generateur_emetteur_id: '1' - }, - donnee_intermediaire: { - rendement_generation: 1, - conso_ch: 6322.706407855126, - conso_ch_depensier: 7679.56883918009, - rg: 1, - rg_dep: 1, - conso_auxiliaire_generation_ch: 0, - conso_auxiliaire_generation_ch_depensier: 0 - } - } - ] - } + [ + genCh('1', { + conso_ch: 1000, + conso_ch_depensier: 1200, + conso_auxiliaire_distribution_ch: 50 + }) + ] + ) + ]; + + const res = calc_conso(100, 1, 1, [], ch, [], [], 1, 1, DATE_DPE, coef_ep); + + // clé 0 => prorataChauffage (1) ; distribution non multipliée (méthode 3) + expect(res.ef_conso.conso_ch).toBe(1000); + expect(res.ef_conso.conso_auxiliaire_distribution_ch).toBe(50); + }); + + test('clé de répartition nulle (prorata 0) : consommation de chauffage annulée', () => { + // cle===1 => value.cle = prorataChauffage(0) ; type collectif => répartition = 0 + const ch = [ + installCh({ cle_repartition_ch: 1, enum_type_installation_id: '2' }, [ + genCh('1', { conso_ch: 1000, conso_ch_depensier: 1200 }) + ]) + ]; + + const res = calc_conso(100, 1, 1, [], ch, [], [], 1, 0, DATE_DPE, coef_ep); + expect(res.ef_conso.conso_ch).toBe(0); + }); + + test('réseau de chaleur connu : le coefficient GES provient de la table réseau', () => { + const ch = [ + installCh({ cle_repartition_ch: 1 }, [ + genCh( + '3', + { conso_ch: 1000, conso_ch_depensier: 1200 }, + { identifiant_reseau_chaleur: 'R1', date_arrete_reseau_chaleur: '2030-01-01' } + ) + ]) + ]; + + const res = calc_conso(100, 1, 1, [], ch, [], [], 1, 1, DATE_DPE, coef_ep); + // contenu_co2_acv = 0,15 (tv mocké) => émission = 1000 * 0,15 + expect(res.emission_ges.emission_ges_ch).toBeCloseTo(150, 9); + }); + + test('réseau de chaleur inconnu (identifiant sans ligne) : erreur signalée', () => { + const ch = [ + installCh({ cle_repartition_ch: 1 }, [ + genCh( + '3', + { conso_ch: 1000, conso_ch_depensier: 1200 }, + { identifiant_reseau_chaleur: 'R2' } + ) + ]) + ]; + + calc_conso(100, 1, 1, [], ch, [], [], 1, 1, DATE_DPE, coef_ep); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('R2')); + }); + + test('réseau de chaleur sans identifiant : coefficient GES forfaitaire 0,385', () => { + const ch = [ + installCh({ cle_repartition_ch: 1 }, [ + genCh('3', { conso_ch: 1000, conso_ch_depensier: 1200 }) + ]) + ]; + + const res = calc_conso(100, 1, 1, [], ch, [], [], 1, 1, DATE_DPE, coef_ep); + // 1000 * 0,385 + expect(res.emission_ges.emission_ges_ch).toBeCloseTo(385, 9); + }); + + test("date de DPE invalide (levée d'exception) : année de repli 2022", () => { + // date_arrete_reseau_chaleur = Symbol => new Date(...) lève => catch => 2022 + const ch = [ + installCh({ cle_repartition_ch: 1 }, [ + genCh( + '1', + { conso_ch: 1000, conso_ch_depensier: 1200 }, + { date_arrete_reseau_chaleur: Symbol('invalide') } + ) + ]) + ]; + + // Ne doit pas lever + expect(() => calc_conso(100, 1, 1, [], ch, [], [], 1, 1, DATE_DPE, coef_ep)).not.toThrow(); + }); + + test('énergie inconnue : coefficient GES par défaut de 1', () => { + const ch = [ + installCh({ cle_repartition_ch: 1 }, [ + genCh('4', { conso_ch: 1000, conso_ch_depensier: 1200 }) + ]) + ]; + + const res = calc_conso(100, 1, 1, [], ch, [], [], 1, 1, DATE_DPE, coef_ep); + // coef_ges['inconnu'] absent => getGesCoeffForGenerateur renvoie 1 => émission = 1000 + expect(res.emission_ges.emission_ges_ch).toBeCloseTo(1000, 9); + }); + + test('coût du gaz naturel : trois paliers tarifaires couverts', () => { + // Trois générateurs gaz avec des consommations dans chaque palier tarifaire + const ch = [ + installCh({ cle_repartition_ch: 1 }, [ + genCh('2', { conso_ch: 3000, conso_ch_depensier: 3000 }), // < 5009 + genCh('2', { conso_ch: 30000, conso_ch_depensier: 30000 }), // < 50055 + genCh('2', { conso_ch: 60000, conso_ch_depensier: 60000 }) // else + ]) + ]; + + const res = calc_conso(100, 1, 1, [], ch, [], [], 1, 1, DATE_DPE, coef_ep); + // Somme des trois paliers : 0,11121*3000 + (230+0,06533*30000) + (415+0,06164*60000) + const attendu = 0.11121 * 3000 + (230 + 0.06533 * 30000) + (415 + 0.06164 * 60000); + expect(res.cout.cout_ch).toBeCloseTo(attendu, 6); + }); + + test("coût de l'électricité : cinq paliers tarifaires couverts", () => { + const ch = [ + installCh({ cle_repartition_ch: 1 }, [ + genCh('1', { conso_ch: 500, conso_ch_depensier: 500 }), // < 1000 + genCh('1', { conso_ch: 1500, conso_ch_depensier: 1500 }), // < 2500 + genCh('1', { conso_ch: 3000, conso_ch_depensier: 3000 }), // < 5000 + genCh('1', { conso_ch: 10000, conso_ch_depensier: 10000 }), // < 15000 + genCh('1', { conso_ch: 20000, conso_ch_depensier: 20000 }) // else + ]) + ]; + + const res = calc_conso(100, 1, 1, [], ch, [], [], 1, 1, DATE_DPE, coef_ep); + const attendu = + 0.29007 * 500 + + (149 + 0.14066 * 1500) + + (122 + 0.15176 * 3000) + + (94 + 0.15735 * 10000) + + (56 + 0.15989 * 20000); + expect(res.cout.cout_ch).toBeCloseTo(attendu, 6); + }); + + test('ECS collective (prorataECS=1) : clé de répartition = clé × rdim', () => { + const ecs = [ + installEcs({ cle_repartition_ecs: 2, rdim: 3 }, [ + genEcs('1', { conso_ecs: 100, conso_ecs_depensier: 120 }) + ]) + ]; + + const res = calc_conso(100, 1, 1, [], [], ecs, [], 1, 1, DATE_DPE, coef_ep); + // clé = 2 * 3 = 6 => conso_ecs = 100 * 6 + expect(res.ef_conso.conso_ecs).toBe(600); + }); + + test('ECS avec prorataECS différent de 1 : clé issue du prorata', () => { + // Quand prorataECS !== 1, la boucle ECS ne renseigne pas donnee_utilisateur : + // on la fournit en amont (coefficient GES déjà connu) pour refléter les données réelles. + const gen = genEcs('1', { conso_ecs: 100, conso_ecs_depensier: 120 }); + gen.donnee_utilisateur = { coeffEmissionGes: 0.065 }; + const ecs = [installEcs({}, [gen])]; + + const res = calc_conso(100, 1, 1, [], [], ecs, [], 0.5, 1, DATE_DPE, coef_ep); + // clé absente => prorataECS (0,5) => conso_ecs = 100 * 0,5 + expect(res.ef_conso.conso_ecs).toBe(50); + }); + + test('ECS sans clé ni rdim, énergie non renseignée : valeurs de repli (1 et électricité)', () => { + // cle_repartition_ecs et rdim absents => 1 ; enum_type_energie_id absent => électricité (id 1) + const ecs = [installEcs({}, [genEcs(undefined, { conso_ecs: 100, conso_ecs_depensier: 120 })])]; + + const res = calc_conso(100, 1, 1, [], [], ecs, [], 1, 1, DATE_DPE, coef_ep); + // clé = 1 * 1 => conso_ecs = 100 ; EP électricité => 100 * 1,9 + expect(res.ef_conso.conso_ecs).toBe(100); + expect(res.ep_conso.ep_conso_ecs).toBeCloseTo(190, 9); + }); + + test('ventilation et froid : auxiliaires et froid agrégés par énergie', () => { + const vt = [ + { + donnee_entree: { cle_repartition_ventilation: 2 }, + donnee_intermediaire: { conso_auxiliaire_ventilation: 20 } + }, + // conso absente => 0 ; pas de clé de répartition + { donnee_entree: {}, donnee_intermediaire: {} } + ]; + const fr = [ + { + donnee_entree: { enum_type_energie_id: '1' }, + donnee_intermediaire: { conso_fr: 200, conso_fr_depensier: 250 } } ]; - const ecs = []; - const fr = []; - const result = calc_conso(Sh, zc_id, ca_id, vt, ch, ecs, fr); + const res = calc_conso(100, 1, 1, vt, [], [], fr, 1, 1, DATE_DPE, coef_ep); + + // ventilation : 20 * 2 (clé) + 0 = 40 + expect(res.ef_conso.conso_auxiliaire_ventilation).toBe(40); + expect(res.ef_conso.conso_fr).toBe(200); + // sortie par énergie pour l'électricité + const elec = res.sortie_par_energie_collection.sortie_par_energie.find( + (e) => e.enum_type_energie_id === '1' + ); + expect(elec.conso_ch).toBeDefined(); + }); + + test('sortie par énergie : électricité ajoutée même en son absence', () => { + // Seul le gaz est présent => l'électricité (id 1) est tout de même ajoutée + const ch = [ + installCh({ cle_repartition_ch: 1 }, [ + genCh('2', { conso_ch: 1000, conso_ch_depensier: 1200 }) + ]) + ]; - expect(result.ef_conso.conso_ch).toBe(6322.706407855126); - expect(result.ef_conso.conso_ch_depensier).toBe(7679.56883918009); + const res = calc_conso(100, 1, 1, [], ch, [], [], 1, 1, DATE_DPE, coef_ep); + const ids = res.sortie_par_energie_collection.sortie_par_energie.map( + (e) => e.enum_type_energie_id + ); + expect(ids).toContain('2'); + expect(ids).toContain('1'); }); }); 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 00000000..97de0860 --- /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/assets/domain/synchronize-solicitations-tables.spec.js b/src/core/assets/domain/synchronize-solicitations-tables.spec.js index e22a3caa..023660a5 100644 --- a/src/core/assets/domain/synchronize-solicitations-tables.spec.js +++ b/src/core/assets/domain/synchronize-solicitations-tables.spec.js @@ -44,4 +44,29 @@ describe('SynchronizeSolicitationsTables unit tests', () => { }); }); }); + + test("regroupe sans clé quand la ligne n'a ni ilpa ni classe_altitude", () => { + const fileStore = new FileStore(); + const appConfig = new ApplicationConfig(); + const synchronizeSolicitationsTables = new SynchronizeSolicitationsTables(fileStore, appConfig); + + // Feuille ne comportant que `mois` et une valeur : `groupKey` doit rester + // vide (branche `: ''`) et le chemin construit est `feuille.mois.clef`. + vi.spyOn(fileStore, 'readLocalOdsFileAndConvertToJson').mockResolvedValue({ + text_ext: [{ mois: 'Janvier', h1a: '12.5' }] + }); + vi.spyOn(ApplicationConfig.prototype, 'solicitationsExtFilePath', 'get').mockReturnValue( + 'src/file.ods' + ); + + return synchronizeSolicitationsTables.execute().then((output) => { + expect(output).toEqual({ + text_ext: { + Janvier: { + h1a: 12.5 + } + } + }); + }); + }); }); diff --git a/src/core/assets/domain/synchronize-valeur-tables.spec.js b/src/core/assets/domain/synchronize-valeur-tables.spec.js index f5fd3f4b..c1e95910 100644 --- a/src/core/assets/domain/synchronize-valeur-tables.spec.js +++ b/src/core/assets/domain/synchronize-valeur-tables.spec.js @@ -51,4 +51,52 @@ describe('SynchronizeValeurTables unit tests', () => { ); }); }); + + test('ajoute les valeurs Ue supplémentaires quand la table `ue` est présente', () => { + const fileStore = new FileStore(); + const appConfig = new ApplicationConfig(); + const synchronizeC1Tables = new SynchronizeC1Tables(fileStore, appConfig); + const synchronizeSolicitationTables = new SynchronizeSolicitationsTables(fileStore, appConfig); + const synchronizeDpeGesLimitValuesTables = new SynchronizeDpeGesLimitValuesTables( + fileStore, + appConfig + ); + const addAdditionnalUeValuesTables = new AddAdditionnalUeValuesTables(fileStore, appConfig); + const synchronizeValeurTables = new SynchronizeValeurTables( + fileStore, + appConfig, + synchronizeSolicitationTables, + synchronizeDpeGesLimitValuesTables, + addAdditionnalUeValuesTables, + synchronizeC1Tables + ); + + vi.spyOn(ApplicationConfig.prototype, 'ademeValeurTablesFileUrl', 'get').mockReturnValue( + 'http://localhost/file.xlsx' + ); + vi.spyOn(ApplicationConfig.prototype, 'assetsOutputFolder', 'get').mockReturnValue( + 'src/assets' + ); + + // Le classeur téléchargé contient une table `ue` : la branche d'ajout des + // valeurs Ue supplémentaires doit être empruntée. + vi.spyOn(fileStore, 'downloadXlsxFileAndConvertToJson').mockResolvedValue({ + ue: [{ type_adjacence: 'ext', ue: '1.5' }] + }); + vi.spyOn(fileStore, 'writeFileToLocalSystem').mockResolvedValue(null); + vi.spyOn(synchronizeC1Tables, 'execute').mockResolvedValue({}); + vi.spyOn(synchronizeSolicitationTables, 'execute').mockResolvedValue({}); + vi.spyOn(synchronizeDpeGesLimitValuesTables, 'execute').mockResolvedValue({}); + const addUeSpy = vi + .spyOn(addAdditionnalUeValuesTables, 'execute') + .mockReturnValue({ ue: 'valeurs-ue-completees' }); + + return synchronizeValeurTables.execute().then(() => { + // La fonction d'ajout reçoit les valeurs fusionnées contenant la table ue. + expect(addUeSpy).toHaveBeenCalledWith(expect.objectContaining({ ue: expect.any(Array) })); + // Le fichier écrit contient le résultat renvoyé par l'ajout Ue. + const contenu = vi.mocked(fileStore.writeFileToLocalSystem).mock.calls[0][1]; + expect(contenu).toContain('valeurs-ue-completees'); + }); + }); }); 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 00000000..bf568dad --- /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/file/infrastructure/adapter/csv-parser.store.spec.js b/src/core/file/infrastructure/adapter/csv-parser.store.spec.js index 80802316..afb8d616 100644 --- a/src/core/file/infrastructure/adapter/csv-parser.store.spec.js +++ b/src/core/file/infrastructure/adapter/csv-parser.store.spec.js @@ -1,28 +1,46 @@ import { beforeEach, describe, expect, it } from 'vitest'; import CsvParserStore from './csv-parser.store.js'; -import { createReadStream } from 'node:fs'; -import { resolve } from 'node:path'; +import { Readable } from 'node:stream'; describe('CsvParserStore', () => { /** @type {CsvParserStore} **/ let store; - /** @type {ReadStream} **/ - let stream; - beforeEach(() => { store = new CsvParserStore(); }); describe('parseFromStream', () => { - it('should parse a csv file from a stream', () => { - stream = createReadStream(resolve('test/corpus/corpus_dpe.csv')); - - return store - .parseFromStream(stream, {}, (data) => data) - .then((data) => { - expect(data).toBeDefined(); - }); + // Flux CSV construit en mémoire pour garder le test isolé (aucune dépendance à un fichier réel). + it('convertit chaque ligne du flux en objet à partir des en-têtes', async () => { + const stream = Readable.from(['numero_dpe,etiquette\nDPE-1,A\nDPE-2,F\n']); + + const data = await store.parseFromStream(stream, { headers: true }, (row) => row); + + expect(data).toStrictEqual([ + { numero_dpe: 'DPE-1', etiquette: 'A' }, + { numero_dpe: 'DPE-2', etiquette: 'F' } + ]); + }); + + it('applique la fonction de transformation à chaque ligne', async () => { + const stream = Readable.from(['numero_dpe,etiquette\nDPE-1,A\n']); + + const data = await store.parseFromStream(stream, { headers: true }, (row) => ({ + id: row.numero_dpe + })); + + expect(data).toStrictEqual([{ id: 'DPE-1' }]); + }); + + it('rejette la promesse lorsque la transformation lève une erreur', async () => { + const stream = Readable.from(['numero_dpe,etiquette\nDPE-1,A\n']); + + await expect( + store.parseFromStream(stream, { headers: true }, () => { + throw new Error('ligne invalide'); + }) + ).rejects.toThrow('ligne invalide'); }); }); }); diff --git a/src/core/file/infrastructure/adapter/file.store.spec.js b/src/core/file/infrastructure/adapter/file.store.spec.js index ba740159..5f692650 100644 --- a/src/core/file/infrastructure/adapter/file.store.spec.js +++ b/src/core/file/infrastructure/adapter/file.store.spec.js @@ -1,5 +1,29 @@ -import { FileStore } from './file.store.js'; -import { describe, expect, test, vi } from 'vitest'; +import { beforeEach, 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)) +})); + +// Isolation : on mocke `xlsx` pour piloter le contenu du classeur retourné et +// tester la logique propre au fichier (fusion de cellules `!merges`) sans +// dépendre du parsing réel d'un binaire. +vi.mock('xlsx', () => ({ + read: vi.fn(() => ({ SheetNames: ['Sheet1'], Sheets: { Sheet1: {} } })), + utils: { + // Colonnes : 0 -> A, 1 -> B, ... + encode_col: vi.fn((c) => String.fromCharCode(65 + c)), + // Lignes : 0 -> 1, 1 -> 2, ... (numérotation 1-based du tableur) + encode_row: vi.fn((r) => String(r + 1)), + sheet_to_json: vi.fn(() => []) + } +})); + +const { readFile, writeFile } = await import('fs'); +const XLSX = await import('xlsx'); +const { FileStore } = await import('./file.store.js'); global.fetch = vi.fn(() => Promise.resolve({ @@ -7,25 +31,137 @@ global.fetch = vi.fn(() => }) ); -describe('FileStore unit tests', () => { - test('should be able to download and parse an xlsx file', () => { - const fileStore = new FileStore(); +describe('FileStore', () => { + beforeEach(() => { + vi.mocked(readFile).mockReset(); + vi.mocked(writeFile).mockReset(); + vi.mocked(XLSX.read).mockReset(); + vi.mocked(XLSX.utils.sheet_to_json).mockReset(); + vi.mocked(XLSX.utils.encode_col).mockClear(); + vi.mocked(XLSX.utils.encode_row).mockClear(); + + // Comportements par défaut « nominaux ». + vi.mocked(readFile).mockImplementation((path, cb) => cb(null, Buffer.from(new ArrayBuffer(1)))); + vi.mocked(writeFile).mockImplementation((path, content, opts, cb) => cb(null)); + vi.mocked(XLSX.read).mockReturnValue({ + SheetNames: ['Sheet1'], + Sheets: { Sheet1: {} } + }); + vi.mocked(XLSX.utils.sheet_to_json).mockReturnValue([]); + }); + + describe('downloadXlsxFileAndConvertToJson', () => { + test('télécharge le fichier puis le convertit en JSON par feuille', async () => { + const fileStore = new FileStore(); + + const output = await fileStore.downloadXlsxFileAndConvertToJson('http://localhost:8080'); - return fileStore.downloadXlsxFileAndConvertToJson('http://localhost:8080').then((output) => { expect(output).toEqual({ Sheet1: [] }); + // Le buffer téléchargé est lu par XLSX avec les bonnes options. + expect(fetch).toHaveBeenCalledWith('http://localhost:8080'); + expect(XLSX.read).toHaveBeenCalledWith(expect.anything(), { + type: 'string', + raw: false + }); }); }); - test('should be able to read and parse local ods file', () => { - const fileStore = new FileStore(); + describe('readLocalOdsFileAndConvertToJson', () => { + test('lit le fichier local et le convertit en JSON par feuille', async () => { + const fileStore = new FileStore(); + + const output = await fileStore.readLocalOdsFileAndConvertToJson('file.ods'); - return fileStore.readLocalOdsFileAndConvertToJson('file.ods').then((output) => { expect(output).toEqual({ Sheet1: [] }); + expect(readFile).toHaveBeenCalledWith('file.ods', expect.any(Function)); + // La lecture locale utilise l'option `type: 'buffer'`. + expect(XLSX.read).toHaveBeenCalledWith(expect.anything(), { + type: 'buffer', + raw: false + }); + }); + + test("journalise et avale l'erreur si la lecture échoue (branche rejet + catch)", async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.mocked(readFile).mockImplementation((path, cb) => cb(new Error('lecture impossible'))); + + const fileStore = new FileStore(); + const output = await fileStore.readLocalOdsFileAndConvertToJson('absent.ods'); + + // Le catch avale l'erreur : la promesse se résout sur `undefined`. + expect(output).toBeUndefined(); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Could not parse file: absent.ods, reason: lecture impossible' + ); + // XLSX ne doit pas être appelé en cas d'erreur de lecture. + expect(XLSX.read).not.toHaveBeenCalled(); + + consoleErrorSpy.mockRestore(); + }); + }); + + describe('writeFileToLocalSystem', () => { + test('écrit le contenu en UTF-8 et résout la promesse', async () => { + const fileStore = new FileStore(); + + await expect( + fileStore.writeFileToLocalSystem('sortie.json', 'contenu') + ).resolves.toBeUndefined(); + + expect(writeFile).toHaveBeenCalledWith( + 'sortie.json', + 'contenu', + { encoding: 'utf-8' }, + expect.any(Function) + ); + }); + + test("rejette la promesse si l'écriture échoue (branche rejet)", async () => { + const erreur = new Error('disque plein'); + vi.mocked(writeFile).mockImplementation((path, content, opts, cb) => cb(erreur)); + + const fileStore = new FileStore(); + + await expect(fileStore.writeFileToLocalSystem('sortie.json', 'contenu')).rejects.toBe(erreur); }); }); - test('should write file to local system', () => { - const fileStore = new FileStore(); - return fileStore.writeFileToLocalSystem('src/output.js', 'filecontent'); + describe('conversion des feuilles avec cellules fusionnées', () => { + test('propage la valeur de la première cellule sur les lignes fusionnées', async () => { + const firstCell = { v: 'valeur fusionnée' }; + const feuille = { + // Fusion de A1 à A3 (3 lignes, colonne 0). + '!merges': [{ s: { c: 0, r: 0 }, e: { c: 0, r: 2 } }], + A1: firstCell + }; + const classeur = { SheetNames: ['Sheet1'], Sheets: { Sheet1: feuille } }; + vi.mocked(XLSX.read).mockReturnValue(classeur); + + const fileStore = new FileStore(); + await fileStore.downloadXlsxFileAndConvertToJson('http://localhost:8080'); + + // nbMergedRows = 2 : les cellules A2 et A3 reçoivent la valeur de A1. + expect(feuille.A2).toBe(firstCell); + expect(feuille.A3).toBe(firstCell); + // La feuille (mutée) est ensuite transformée en JSON. + expect(XLSX.utils.sheet_to_json).toHaveBeenCalledWith(feuille, { + raw: false + }); + }); + + test("ne modifie pas la feuille en l'absence de fusion", async () => { + const feuille = { A1: { v: 'x' } }; + vi.mocked(XLSX.read).mockReturnValue({ + SheetNames: ['Sheet1'], + Sheets: { Sheet1: feuille } + }); + + const fileStore = new FileStore(); + await fileStore.downloadXlsxFileAndConvertToJson('http://localhost:8080'); + + // Aucune cellule supplémentaire n'est créée. + expect(Object.keys(feuille)).toEqual(['A1']); + expect(XLSX.utils.encode_col).not.toHaveBeenCalled(); + }); }); }); 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 00000000..7567540c --- /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/infrastructure/object-util.spec.js b/src/core/util/infrastructure/object-util.spec.js index 50a7d4b8..ef752f85 100644 --- a/src/core/util/infrastructure/object-util.spec.js +++ b/src/core/util/infrastructure/object-util.spec.js @@ -1,7 +1,27 @@ import { ObjectUtil } from './object-util.js'; import { describe, expect, test } from 'vitest'; +const identite = (x) => x; + describe('ObjectUtil unit tests', () => { + test("retourne la primitive telle quelle quand la racine n'est ni objet ni tableau", () => { + // Branche terminale `: obj` : une primitive n'est pas transformée. + expect(ObjectUtil.deepObjectTransform('brut', identite, identite)).toBe('brut'); + expect(ObjectUtil.deepObjectTransform(42, identite, identite)).toBe(42); + }); + + test('conserve une valeur nulle sans tenter de la parcourir récursivement', () => { + // Branche `val !== null` : `null` est de type "object" mais ne doit pas + // être exploré récursivement. + const resultat = ObjectUtil.deepObjectTransform( + { a: null }, + (key) => key.toUpperCase(), + identite + ); + + expect(resultat).toEqual({ A: null }); + }); + test('should be able to deeply transform an object keys and values', () => { expect( ObjectUtil.deepObjectTransform( diff --git a/src/core/util/logger/log-service.js b/src/core/util/logger/log-service.js index f98df188..45eafee4 100644 --- a/src/core/util/logger/log-service.js +++ b/src/core/util/logger/log-service.js @@ -2,6 +2,21 @@ import { createLogger, format, transports } from 'winston'; const { combine, timestamp, prettyPrint, colorize, errors, printf } = format; +/** + * Formate une ligne de log. Si une pile (`stack`) est présente, elle est + * ajoutée à la suite du message. Fonction pure exportée pour être testée + * unitairement. + * @param {{level: string, message: string, timestamp: string, stack?: string}} info + * @return {string} + */ +export const formatLogLine = ({ level, message, timestamp, stack }) => { + if (stack) { + // print log trace + return `${timestamp} ${level}: ${message} - ${stack}`; + } + return `${timestamp} ${level}: ${message}`; +}; + // Disable traditional console logs const copyLog = console.log; const copyWarn = console.warn; @@ -15,13 +30,7 @@ export const logger = createLogger({ colorize(), timestamp(), prettyPrint(), - printf(({ level, message, timestamp, stack }) => { - if (stack) { - // print log trace - return `${timestamp} ${level}: ${message} - ${stack}`; - } - return `${timestamp} ${level}: ${message}`; - }) + printf(formatLogLine) ), transports: [new transports.Console()] }); 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 00000000..6daff5fb --- /dev/null +++ b/src/core/util/logger/log-service.spec.js @@ -0,0 +1,101 @@ +import { afterEach, describe, expect, test } from 'vitest'; +import { formatLogLine, 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(); + }); + + test('toutes les méthodes console neutralisées (log/warn/debug/error) sont des no-op', () => { + setLoggerOff(true); + + // On invoque chaque no-op pour vérifier l'absence d'effet/erreur. + expect(console.log('x')).toBeUndefined(); + expect(console.warn('x')).toBeUndefined(); + expect(console.debug('x')).toBeUndefined(); + expect(console.error('x')).toBeUndefined(); + }); + + test('formatLogLine formate un message simple sans pile (branche sans stack)', () => { + const ligne = formatLogLine({ + level: 'info', + message: 'message simple', + timestamp: '2026-09-04T00:00:00' + }); + + // Sans pile, aucun séparateur de trace n'est ajouté. + expect(ligne).toBe('2026-09-04T00:00:00 info: message simple'); + expect(ligne).not.toContain(' - '); + }); + + test('formatLogLine ajoute la pile quand elle est présente (branche stack)', () => { + const ligne = formatLogLine({ + level: 'error', + message: 'boom', + timestamp: '2026-09-04T00:00:00', + stack: 'Error: boom\n at fichier.js:1:1' + }); + + // La présence d'une pile insère le séparateur " - " suivi de la trace. + expect(ligne).toBe('2026-09-04T00:00:00 error: boom - Error: boom\n at fichier.js:1:1'); + }); +}); diff --git a/src/dpe-sanitizer.service.spec.js b/src/dpe-sanitizer.service.spec.js new file mode 100644 index 00000000..08d0334f --- /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.js b/src/engine.js index 2426075d..5fb6bd31 100644 --- a/src/engine.js +++ b/src/engine.js @@ -612,6 +612,9 @@ export function calcul_3cl(inputDpe, options) { ); // get all baie_vitree orientations + // repli défensif inatteignable : la garde en amont réinitialise plancher_haut_collection + // à { plancher_haut: [] } (tableau vide truthy), donc plancher_haut est toujours défini + /* c8 ignore next */ const ph_list = env.plancher_haut_collection.plancher_haut || []; logement.sortie = { deperdition, diff --git a/src/engine.spec.js b/src/engine.spec.js new file mode 100644 index 00000000..5a2147c6 --- /dev/null +++ b/src/engine.spec.js @@ -0,0 +1,854 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +/** + * `engine.js` est l'orchestrateur du moteur. Il enchaîne de nombreux sous-modules + * de calcul (déperdition, apport/besoin, ecs, chauffage, conso, ...). Pour le + * tester **unitairement**, TOUS ces sous-modules sont mockés : ils renvoient des + * valeurs contrôlées, ce qui permet de piloter chacune des branches de + * l'orchestrateur sans exécuter la moindre logique métier. + * + * On vérifie que l'orchestrateur : + * - garde-fou sur le modèle / l'enveloppe (retours `null`) ; + * - choisit les bonnes surfaces (Sh / Sh immeuble) selon `th` et le map_id ; + * - construit et transmet les bons arguments aux sous-modules ; + * - agrège correctement les sorties (`sortie`, `ep_conso`, coefficients). + */ + +// Drapeaux mutables pilotant les constantes de configuration importées depuis utils. +// Les getters garantissent une lecture « live » côté module testé. +let bugForBugCompat = false; +let useEnumAsStringFlag = false; + +vi.mock('./utils.js', () => ({ + add_references: vi.fn(), + get bug_for_bug_compat() { + return bugForBugCompat; + }, + collectionCanBeEmpty: vi.fn(), + containsAnySubstring: vi.fn(), + isEffetJoule: vi.fn(), + get use_enum_as_string() { + return useEnumAsStringFlag; + }, + useEnumAsString: vi.fn(), + xmlParser: { parse: vi.fn() } +})); + +vi.mock('./enums.js', () => ({ + default: { + methode_application_dpe_log: {}, + modele_dpe: {}, + orientation: {}, + type_vitrage: {}, + type_baie: {}, + type_materiaux_menuiserie: {}, + type_generateur_ch: {}, + type_generateur_ecs: {} + } +})); + +vi.mock('./conso.js', () => ({ + default: vi.fn(), + classe_bilan_dpe: vi.fn(), + classe_emission_ges: vi.fn(), + coef_ep: { tag: 'coef_ep' }, + coef_ep_1_7: { tag: 'coef_ep_1_7' }, + coef_ep_2_3: { tag: 'coef_ep_2_3' }, + COEFF_EP_1_7: 1.7, + COEFF_EP_2_3: 2.3 +})); + +// Dépendances de calcul mockées : elles ne doivent pas s'exécuter réellement. +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('./ficheTechnique.js', () => ({ default: vi.fn() })); + +// Les instances Inertie / ProductionENR sont créées au chargement du module. +// On expose des méthodes mockées récupérables dans les tests. +const calculateInertie = vi.fn(); +const calculateEnr = vi.fn(); +vi.mock('./7_inertie.js', () => ({ Inertie: vi.fn(() => ({ calculateInertie })) })); +vi.mock('./16.2_production_enr.js', () => ({ ProductionENR: vi.fn(() => ({ calculateEnr })) })); + +// Le service de sanitisation est instancié au chargement puis appelé : `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_7_2027, 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 calc_conso = (await import('./conso.js')).default; +const { + add_references, + collectionCanBeEmpty, + containsAnySubstring, + isEffetJoule, + useEnumAsString, + xmlParser +} = await import('./utils.js'); +const enums = (await import('./enums.js')).default; +const calc_deperdition = (await import('./3_deperdition.js')).default; +const calc_apport_et_besoin = (await import('./apport_et_besoin.js')).default; +const calc_clim = (await import('./10_clim.js')).default; +const calc_ecs = (await import('./11_ecs.js')).default; +const calc_besoin_ch = (await import('./9_besoin_ch.js')).default; +const { default: calc_chauffage, tauxChargeForGenerator } = await import('./9_chauffage.js'); +const calc_confort_ete = (await import('./2021_04_13_confort_ete.js')).default; +const calc_qualite_isolation = (await import('./2021_04_13_qualite_isolation.js')).default; +const getFicheTechnique = (await import('./ficheTechnique.js')).default; + +/** Objet conso renvoyé par le mock de `calc_conso` (mêmes réf pour les 3 appels). */ +let consoResult; + +beforeEach(() => { + vi.clearAllMocks(); + bugForBugCompat = false; + useEnumAsStringFlag = false; + + // Enums par défaut : maison, modèle supporté. + enums.methode_application_dpe_log = { 1: 'dpe maison individuelle' }; + enums.modele_dpe = { 1: 'dpe 3cl 2021 méthode logement', 99: 'modèle non supporté' }; + enums.orientation = { 1: 'sud' }; + enums.type_vitrage = { 1: 'double' }; + enums.type_baie = { 1: 'fenetre' }; + enums.type_materiaux_menuiserie = { 1: 'pvc' }; + enums.type_generateur_ch = {}; + enums.type_generateur_ecs = {}; + + // Retours par défaut des sous-modules (chemin nominal). + vi.mocked(sanitizeExecute).mockImplementation((dpe) => dpe); + vi.mocked(collectionCanBeEmpty).mockReturnValue(true); + vi.mocked(isEffetJoule).mockReturnValue(false); + vi.mocked(getFicheTechnique).mockReturnValue(undefined); + vi.mocked(containsAnySubstring).mockReturnValue(false); + vi.mocked(calc_deperdition).mockReturnValue({ deperdition_enveloppe: 300 }); + vi.mocked(calculateInertie).mockReturnValue({ enum_classe_inertie_id: '3' }); + vi.mocked(calc_apport_et_besoin).mockReturnValue({ + besoin_fr: 1, + besoin_fr_depensier: 2, + besoin_ecs: 100, + besoin_ecs_depensier: 120, + nadeq: 2 + }); + vi.mocked(calc_besoin_ch).mockReturnValue({ + besoin_ch: 50, + besoin_ch_depensier: 60, + besoin_ch_mois: { m1: 10, m2: 20 } + }); + consoResult = { + ep_conso: { classe_bilan_dpe: 'D', ep_conso_5_usages: 100, ep_conso_5_usages_m2: 5 }, + ef_conso: {} + }; + vi.mocked(calc_conso).mockReturnValue(consoResult); + vi.mocked(calculateEnr).mockReturnValue({ pv: 1 }); + vi.mocked(calc_confort_ete).mockReturnValue('confort'); + vi.mocked(calc_qualite_isolation).mockReturnValue('qualite'); +}); + +/** Construit un DPE complet exploitable par un run intégral de `calcul_3cl`. */ +function makeDpe() { + return { + administratif: { + enum_modele_dpe_id: '1', + date_etablissement_dpe: '2024-01-01' + }, + logement: { + meteo: { + enum_zone_climatique_id: 'zc1', + enum_classe_altitude_id: 'ca1', + batiment_materiaux_anciens: 0 + }, + caracteristique_generale: { + enum_methode_application_dpe_log_id: '1', + surface_habitable_logement: 80, + surface_habitable_immeuble: 200, + nombre_appartement: 4, + hsp: 2.5, + annee_construction: 1990 + }, + enveloppe: { + mur_collection: { mur: [{ donnee_entree: {} }] }, + plancher_haut_collection: { plancher_haut: [{ donnee_entree: {} }] }, + plancher_bas_collection: { plancher_bas: [{ donnee_entree: {} }] }, + baie_vitree_collection: { baie_vitree: [{ donnee_entree: { presence_joint: 1 } }] }, + ets_collection: { ets: [] }, + inertie: { enum_classe_inertie_id: '3' } + }, + installation_chauffage_collection: { + installation_chauffage: [ + { donnee_entree: {}, generateur_chauffage_collection: { generateur_chauffage: [] } } + ] + }, + installation_ecs_collection: { + installation_ecs: [ + { + donnee_entree: { enum_type_installation_id: '1' }, + generateur_ecs_collection: { generateur_ecs: [] } + } + ] + }, + climatisation_collection: { climatisation: [{ donnee_entree: {} }] }, + ventilation_collection: { ventilation: [{ donnee_entree: {} }] }, + production_elec_enr: { some: 'pv' } + } + }; +} + +/** Construit un générateur ECS pour les tests du bloc `bug_for_bug_compat`. */ +function genEcs(overrides = {}) { + return { donnee_entree: { ...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 = makeDpe(); + dpe.logement.sortie = { + ep_conso: { ep_conso_5_usages_m2: 125 }, + emission_ges: { emission_ges_5_usages_m2: 30 } + }; + 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, 'zc1', 'ca1', 80); + expect(classe_emission_ges).toHaveBeenCalledWith(30, 'zc1', 'ca1', 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'); + + const dpe = makeDpe(); + dpe.logement.sortie = { + ep_conso: { ep_conso_5_usages_m2: 125 }, + emission_ges: { emission_ges_5_usages_m2: 30 } + }; + get_classe_ges_dpe(dpe); + + // Sh = surface_habitable_immeuble + expect(classe_bilan_dpe).toHaveBeenCalledWith(125, 'zc1', 'ca1', 200); + }); + + test('méthode application inconnue : th null, surface indéterminée', () => { + // calc_th ne reconnaît ni maison ni appartement ni immeuble => null + enums.methode_application_dpe_log = { 1: 'méthode farfelue' }; + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.mocked(classe_bilan_dpe).mockReturnValue('G'); + vi.mocked(classe_emission_ges).mockReturnValue('G'); + + const dpe = makeDpe(); + dpe.logement.sortie = { + ep_conso: { ep_conso_5_usages_m2: 400 }, + emission_ges: { emission_ges_5_usages_m2: 80 } + }; + get_classe_ges_dpe(dpe); + + // th null => Sh reste indéfini + expect(classe_bilan_dpe).toHaveBeenCalledWith(400, 'zc1', 'ca1', undefined); + expect(errSpy).toHaveBeenCalled(); + errSpy.mockRestore(); + }); +}); + +describe('calcul_3cl - garde-fous précoces', () => { + test('retourne null pour un modèle de DPE non supporté', () => { + const dpe = { administratif: { enum_modele_dpe_id: '99' } }; + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const res = calcul_3cl(dpe, { sanitize: false }); + + expect(res).toBeNull(); + errSpy.mockRestore(); + }); + + test("sanitise l'entrée par défaut avant de vérifier le modèle", () => { + const dpe = { administratif: { enum_modele_dpe_id: '99' } }; + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const res = calcul_3cl(dpe); // pas d'options => sanitize par défaut + + expect(sanitizeExecute).toHaveBeenCalledWith(dpe); + expect(res).toBeNull(); + errSpy.mockRestore(); + }); + + test("sans sanitisation, applique useEnumAsString si l'option globale est active", () => { + useEnumAsStringFlag = true; + const dpe = { administratif: { enum_modele_dpe_id: '99' } }; + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + calcul_3cl(dpe, { sanitize: false }); + + expect(sanitizeExecute).not.toHaveBeenCalled(); + expect(useEnumAsString).toHaveBeenCalledWith(dpe); + errSpy.mockRestore(); + }); + + test('retourne null si enveloppe absente', () => { + const dpe = makeDpe(); + delete dpe.logement.enveloppe; + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + expect(calcul_3cl(dpe, { sanitize: false })).toBeNull(); + warnSpy.mockRestore(); + }); + + test('retourne null si mur_collection absent', () => { + const dpe = makeDpe(); + dpe.logement.enveloppe.mur_collection = undefined; + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + expect(calcul_3cl(dpe, { sanitize: false })).toBeNull(); + warnSpy.mockRestore(); + }); + + test('plancher_haut vide + non-vidable => null', () => { + vi.mocked(collectionCanBeEmpty).mockReturnValue(false); + const dpe = makeDpe(); + dpe.logement.enveloppe.plancher_haut_collection = { plancher_haut: [] }; + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + expect(calcul_3cl(dpe, { sanitize: false })).toBeNull(); + expect(collectionCanBeEmpty).toHaveBeenCalledWith(dpe.logement, 'plancher_haut', 3); + errSpy.mockRestore(); + }); + + test('plancher_bas vide + non-vidable => null', () => { + vi.mocked(collectionCanBeEmpty).mockReturnValue(false); + const dpe = makeDpe(); + // plancher_haut non vide pour atteindre le contrôle plancher_bas + dpe.logement.enveloppe.plancher_bas_collection = { plancher_bas: [] }; + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + expect(calcul_3cl(dpe, { sanitize: false })).toBeNull(); + expect(collectionCanBeEmpty).toHaveBeenCalledWith(dpe.logement, 'plancher_bas', 1); + errSpy.mockRestore(); + }); +}); + +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 errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const res = calcul_3cl_xml('', { sanitize: false }); + + expect(xmlParser.parse).toHaveBeenCalledWith(''); + expect(res).toBeNull(); + errSpy.mockRestore(); + }); +}); + +describe('calcul_3cl - run intégral (maison)', () => { + test('orchestre les sous-modules et agrège la sortie', () => { + const dpe = makeDpe(); + + const res = calcul_3cl(dpe, { sanitize: false }); + + // Le DPE (muté) est retourné. + expect(res).toBe(dpe); + + // Surfaces : maison => Sh = surface_habitable_logement ; map '1' hors liste + // immeuble => ShChauffageAndEcs = Sh. + expect(calc_deperdition).toHaveBeenCalledWith( + dpe.logement.caracteristique_generale, + 'zc1', + 'maison', + false, + dpe, + 80 + ); + + // Références ajoutées à l'enveloppe. + expect(add_references).toHaveBeenCalledWith(dpe.logement.enveloppe); + + // Version moteur injectée. + expect(dpe.administratif.diagnostiqueur).toEqual({ + version_moteur_calcul: 'Open3CL OPEN3CL_VERSION' + }); + + // besoin_ch fusionné, besoin_ch_mois retiré de apport_et_besoin. + expect(dpe.logement.sortie.apport_et_besoin.besoin_ch).toBe(50); + expect(dpe.logement.sortie.apport_et_besoin.besoin_ch_mois).toBeUndefined(); + + // Taux de charge appelé avant la 2ᵉ passe chauffage. + expect(tauxChargeForGenerator).toHaveBeenCalledWith( + dpe.logement.installation_chauffage_collection.installation_chauffage, + 300, + 'ca1', + 'zc1', + 'maison' + ); + + // calc_chauffage appelé 2 fois par installation (1ʳᵉ et 2ᵉ passe). + expect(calc_chauffage).toHaveBeenCalledTimes(2); + // Première passe : besoins à 0. + expect(calc_chauffage.mock.calls[0][6]).toBe(0); + expect(calc_chauffage.mock.calls[0][7]).toBe(0); + // Deuxième passe : besoins réels + besoin_ch_mois. + expect(calc_chauffage.mock.calls[1][6]).toBe(50); + expect(calc_chauffage.mock.calls[1][7]).toBe(60); + expect(calc_chauffage.mock.calls[1][13]).toEqual({ m1: 10, m2: 20 }); + // ilpa = '0' (materiaux anciens = 0). + expect(calc_chauffage.mock.calls[0][12]).toBe('0'); + + // 3 calculs de conso (coef_ep, coef_ep_1_7, coef_ep_2_3). + expect(calc_conso).toHaveBeenCalledTimes(3); + expect(calc_conso.mock.calls[0][10]).toEqual({ tag: 'coef_ep' }); + expect(calc_conso.mock.calls[1][10]).toEqual({ tag: 'coef_ep_1_7' }); + expect(calc_conso.mock.calls[2][10]).toEqual({ tag: 'coef_ep_2_3' }); + // prorata ECS / chauffage neutres pour une maison. + expect(calc_conso.mock.calls[0][7]).toBe(1); + expect(calc_conso.mock.calls[0][8]).toBe(1); + + // 3 calculs de production ENR ; le 1ᵉʳ sans coefficient. + expect(calculateEnr).toHaveBeenCalledTimes(3); + expect(calculateEnr.mock.calls[0]).toEqual([ + dpe.logement.production_elec_enr, + consoResult, + 80, + 'maison', + 'zc1' + ]); + expect(calculateEnr.mock.calls[1][5]).toBe(1.7); + expect(calculateEnr.mock.calls[2][5]).toBe(2.3); + + // Sorties agrégées. + expect(dpe.logement.sortie.deperdition).toEqual({ deperdition_enveloppe: 300 }); + expect(dpe.logement.sortie.confort_ete).toBe('confort'); + expect(dpe.logement.sortie.qualite_isolation).toBe('qualite'); + expect(dpe.logement.sortie.production_electricite).toEqual({ pv: 1 }); + expect(calc_confort_ete).toHaveBeenCalledWith( + '3', + dpe.logement.enveloppe.baie_vitree_collection.baie_vitree, + [dpe.logement.enveloppe.plancher_haut_collection.plancher_haut[0]] + ); + + // ep_conso enrichi des coefficients 1.7 / 2.3 et de la projection 2027. + expect(dpe.logement.sortie.ep_conso.coeff_2_3_classe_bilan_dpe).toBe('D'); + expect(dpe.logement.sortie.ep_conso.coeff_1_7_ep_conso_5_usages).toBe(100); + expect(dpe.logement.sortie.ep_conso.classe_bilan_dpe_2027).toBe('D'); + expect(dpe.logement.sortie.ep_conso.ep_conso_5_usages_2027).toBe(100); + + // ECS individuelle unique : calc_ecs appelé une fois, sans division du besoin. + expect(calc_ecs).toHaveBeenCalledTimes(1); + expect(calc_ecs.mock.calls[0][2]).toBe(100); + expect(calc_ecs.mock.calls[0][11]).toBe(false); + + // Climatisation présente : calc_clim appelé. + expect(calc_clim).toHaveBeenCalledTimes(1); + }); + + test('bâtiment matériaux anciens => ilpa = 1 ; incohérence inertie => erreur loggée', () => { + const dpe = makeDpe(); + dpe.logement.meteo.batiment_materiaux_anciens = 1; + vi.mocked(calculateInertie).mockReturnValue({ enum_classe_inertie_id: '2' }); // != '3' + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + calcul_3cl(dpe, { sanitize: false }); + + expect(calc_chauffage.mock.calls[0][12]).toBe('1'); + expect(errSpy).toHaveBeenCalled(); + errSpy.mockRestore(); + }); + + test('collections ECS/clim absentes et nombre_appartement absent => valeurs par défaut', () => { + const dpe = makeDpe(); + dpe.logement.installation_ecs_collection.installation_ecs = undefined; + dpe.logement.climatisation_collection.climatisation = undefined; + dpe.logement.caracteristique_generale.nombre_appartement = undefined; + + calcul_3cl(dpe, { sanitize: false }); + + // Nb_lgt par défaut = 1 (6ᵉ argument d'apport_et_besoin). + expect(calc_apport_et_besoin.mock.calls[0][5]).toBe(1); + // Pas d'ECS ni de clim à traiter. + expect(calc_ecs).not.toHaveBeenCalled(); + expect(calc_clim).not.toHaveBeenCalled(); + }); +}); + +describe('calcul_3cl - collections vidables (poursuite du calcul)', () => { + test('plancher_haut vide mais vidable : réinitialisé et calcul poursuivi', () => { + const dpe = makeDpe(); + dpe.logement.enveloppe.plancher_haut_collection = { plancher_haut: [] }; + + const res = calcul_3cl(dpe, { sanitize: false }); + + expect(res).toBe(dpe); + expect(collectionCanBeEmpty).toHaveBeenCalledWith(dpe.logement, 'plancher_haut', 3); + expect(dpe.logement.enveloppe.plancher_haut_collection).toEqual({ plancher_haut: [] }); + // ph_list vide passé au confort d'été. + expect(calc_confort_ete.mock.calls[0][2]).toEqual([]); + }); + + test('plancher_bas vide mais vidable : réinitialisé et calcul poursuivi', () => { + const dpe = makeDpe(); + dpe.logement.enveloppe.plancher_bas_collection = { plancher_bas: [] }; + + const res = calcul_3cl(dpe, { sanitize: false }); + + expect(res).toBe(dpe); + expect(collectionCanBeEmpty).toHaveBeenCalledWith(dpe.logement, 'plancher_bas', 1); + expect(dpe.logement.enveloppe.plancher_bas_collection).toEqual({ plancher_bas: [] }); + }); +}); + +describe('calcul_3cl - surfaces immeuble / appartement et prorata', () => { + test('immeuble : Sh = surface immeuble ; ECS collective individualisée non divisée', () => { + enums.methode_application_dpe_log = { 1: 'dpe immeuble collectif' }; + const dpe = makeDpe(); + // Deux systèmes ECS individuels en immeuble => pas de division du besoin. + dpe.logement.installation_ecs_collection.installation_ecs = [ + { + donnee_entree: { enum_type_installation_id: '1' }, + generateur_ecs_collection: { generateur_ecs: [] } + }, + { + donnee_entree: { enum_type_installation_id: '1' }, + generateur_ecs_collection: { generateur_ecs: [] } + } + ]; + + calcul_3cl(dpe, { sanitize: false }); + + // th immeuble => Sh = surface_habitable_immeuble. + expect(calc_deperdition.mock.calls[0][5]).toBe(200); + expect(calc_deperdition.mock.calls[0][2]).toBe('immeuble'); + // isImmeubleSystemEcsIndividuels = true, besoin non divisé. + expect(calc_ecs.mock.calls[0][11]).toBe(true); + expect(calc_ecs.mock.calls[0][2]).toBe(100); + }); + + test('immeuble : systèmes ECS non tous individuels => besoin divisé par deux', () => { + enums.methode_application_dpe_log = { 1: 'dpe immeuble collectif' }; + const dpe = makeDpe(); + dpe.logement.installation_ecs_collection.installation_ecs = [ + { + donnee_entree: { enum_type_installation_id: '1' }, + generateur_ecs_collection: { generateur_ecs: [] } + }, + { + donnee_entree: { enum_type_installation_id: '2' }, + generateur_ecs_collection: { generateur_ecs: [] } + } + ]; + + calcul_3cl(dpe, { sanitize: false }); + + expect(calc_ecs.mock.calls[0][11]).toBe(false); + expect(calc_ecs.mock.calls[0][2]).toBe(50); // 100 / 2 + }); + + test('maison avec deux systèmes ECS => besoin divisé par deux', () => { + const dpe = makeDpe(); + dpe.logement.installation_ecs_collection.installation_ecs = [ + { + donnee_entree: { enum_type_installation_id: '1' }, + generateur_ecs_collection: { generateur_ecs: [] } + }, + { + donnee_entree: { enum_type_installation_id: '1' }, + generateur_ecs_collection: { generateur_ecs: [] } + } + ]; + + calcul_3cl(dpe, { sanitize: false }); + + // th != immeuble => isImmeubleSystemEcsIndividuels false, besoin divisé. + expect(calc_ecs.mock.calls[0][11]).toBe(false); + expect(calc_ecs.mock.calls[0][2]).toBe(50); + }); + + test('appartement issu immeuble (map 15) : Sh immeuble pour chauffage/ECS et prorata surfacique', () => { + enums.methode_application_dpe_log = { 15: 'dpe appartement collectif' }; + const dpe = makeDpe(); + dpe.logement.caracteristique_generale.enum_methode_application_dpe_log_id = '15'; + + calcul_3cl(dpe, { sanitize: false }); + + // ShChauffageAndEcs = surface immeuble (200) pour la déperdition. + expect(calc_deperdition.mock.calls[0][5]).toBe(200); + // conso : Sh = surface logement (80) et prorata = 80/200 = 0.4. + expect(calc_conso.mock.calls[0][0]).toBe(80); + expect(calc_conso.mock.calls[0][7]).toBeCloseTo(0.4, 9); // prorataECS + expect(calc_conso.mock.calls[0][8]).toBeCloseTo(0.4, 9); // prorataChauffage + }); +}); + +describe('calcul_3cl - présence de joint des baies vitrées', () => { + function dpeAvecBaieSansJoint() { + const dpe = makeDpe(); + dpe.logement.enveloppe.baie_vitree_collection = { + baie_vitree: [ + { + donnee_entree: { + description: 'baie 1', + enum_orientation_id: '1', + enum_type_vitrage_id: '1', + enum_type_baie_id: '1', + enum_type_materiaux_menuiserie_id: '1' + // presence_joint absent + } + } + ] + }; + return dpe; + } + + test('fiche technique "Oui" => présence de joint = 1', () => { + vi.mocked(getFicheTechnique).mockImplementation((d, id, key) => + id === '4' && key === 'joint' ? { valeur: 'Oui' } : undefined + ); + const dpe = dpeAvecBaieSansJoint(); + + calcul_3cl(dpe, { sanitize: false }); + + expect( + dpe.logement.enveloppe.baie_vitree_collection.baie_vitree[0].donnee_entree.presence_joint + ).toBe(1); + }); + + test('fiche technique "Non" => présence de joint = 0', () => { + vi.mocked(getFicheTechnique).mockImplementation((d, id, key) => + id === '4' && key === 'joint' ? { valeur: 'Non' } : undefined + ); + const dpe = dpeAvecBaieSansJoint(); + + calcul_3cl(dpe, { sanitize: false }); + + expect( + dpe.logement.enveloppe.baie_vitree_collection.baie_vitree[0].donnee_entree.presence_joint + ).toBe(0); + }); + + test('fiche technique de valeur inconnue => avertissement, présence de joint inchangée', () => { + vi.mocked(getFicheTechnique).mockImplementation((d, id, key) => + id === '4' && key === 'joint' ? { valeur: 'Peut-être' } : undefined + ); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const dpe = dpeAvecBaieSansJoint(); + + calcul_3cl(dpe, { sanitize: false }); + + expect( + dpe.logement.enveloppe.baie_vitree_collection.baie_vitree[0].donnee_entree.presence_joint + ).toBeUndefined(); + expect(warnSpy).toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + test('absence de fiche technique => présence de joint reste indéterminée', () => { + // getFicheTechnique renvoie undefined (défaut) + const dpe = dpeAvecBaieSansJoint(); + + calcul_3cl(dpe, { sanitize: false }); + + expect( + dpe.logement.enveloppe.baie_vitree_collection.baie_vitree[0].donnee_entree.presence_joint + ).toBeUndefined(); + }); +}); + +describe('calcul_3cl - réalignements ECS (bug_for_bug_compat)', () => { + test('réaligne position_volume_chauffe et type de générateur ECS depuis le mixte de chauffage', () => { + bugForBugCompat = true; + enums.type_generateur_ch = { CH1: 'chaudiere gaz' }; + enums.type_generateur_ecs = { ECS9: 'chaudiere gaz' }; + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + // La fiche technique '8' impose "hors volume habitable" => position 0. + vi.mocked(getFicheTechnique).mockImplementation((d, id) => + id === '8' ? { valeur: 'hors volume habitable', description: 'ft8' } : undefined + ); + vi.mocked(containsAnySubstring).mockReturnValue(true); // => pvc = 0 + + const dpe = makeDpe(); + dpe.logement.installation_chauffage_collection.installation_chauffage = [ + { + donnee_entree: {}, + generateur_chauffage_collection: { + generateur_chauffage: [ + { + donnee_entree: { + reference_generateur_mixte: 'MX1', + enum_type_generateur_ch_id: 'CH1' + } + } + ] + } + } + ]; + const generateur = genEcs({ + description: 'gen1', + position_volume_chauffe: 1, + enum_usage_generateur_id: '3', + reference_generateur_mixte: 'MX1', + enum_type_generateur_ecs_id: 'OLD' + }); + dpe.logement.installation_ecs_collection.installation_ecs = [ + { + donnee_entree: { enum_type_installation_id: '1' }, + generateur_ecs_collection: { generateur_ecs: [generateur] } + } + ]; + + calcul_3cl(dpe, { sanitize: false }); + + // position réalignée à 0 et type ECS aligné sur le libellé du générateur mixte. + expect(generateur.donnee_entree.position_volume_chauffe).toBe(0); + expect(generateur.donnee_entree.enum_type_generateur_ecs_id).toBe('ECS9'); + expect(errSpy).toHaveBeenCalled(); + errSpy.mockRestore(); + }); + + test('couvre les branches sans réalignement (fiche absente, mixte introuvable, libellés non résolus)', () => { + bugForBugCompat = true; + enums.type_generateur_ch = { CH1: 'chaudiere gaz', CHX: 'labelX' }; + enums.type_generateur_ecs = { SAME: 'labelX' }; + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + // Fiche '8' uniquement pour la description 'withFiche' ; valeur non "hors volume". + vi.mocked(getFicheTechnique).mockImplementation((d, id, key, arr) => { + if (id === '8' && arr?.[0] === 'withFiche') return { valeur: 'oui', description: 'ft8' }; + return undefined; + }); + // containsAnySubstring false => pvc = 1 (égal à la position courante => pas de réalignement). + vi.mocked(containsAnySubstring).mockReturnValue(false); + + const dpe = makeDpe(); + dpe.logement.installation_chauffage_collection.installation_chauffage = [ + { + donnee_entree: {}, + generateur_chauffage_collection: { + generateur_chauffage: [ + { + donnee_entree: { + reference_generateur_mixte: 'MX_labelnull', + enum_type_generateur_ch_id: 'INCONNU' + } + }, + { + donnee_entree: { + reference_generateur_mixte: 'MX_chgaz', + enum_type_generateur_ch_id: 'CH1' + } + }, + { + donnee_entree: { + reference_generateur_mixte: 'MX_X', + enum_type_generateur_ch_id: 'CHX' + } + } + ] + } + } + ]; + + const generateurs = [ + // Fiche présente, pvc calculé (=1) égal à la position => pas de réalignement ; + // usage '3' mais mixte introuvable (ref inconnue). + genEcs({ + description: 'withFiche', + position_volume_chauffe: 1, + enum_usage_generateur_id: '3', + reference_generateur_mixte: 'AUCUN' + }), + // Pas de description (fiche ignorée) ; usage != 3. + genEcs({ enum_usage_generateur_id: '1' }), + // Description sans fiche ; usage '3' mais pas de référence mixte. + genEcs({ description: 'noFiche', enum_usage_generateur_id: '3' }), + // Mixte trouvé mais libellé de générateur de chauffage non résolu. + genEcs({ + enum_usage_generateur_id: '3', + reference_generateur_mixte: 'MX_labelnull', + enum_type_generateur_ecs_id: 'X0' + }), + // Mixte + libellé résolus mais aucun type ECS correspondant => pas de réalignement. + genEcs({ + enum_usage_generateur_id: '3', + reference_generateur_mixte: 'MX_chgaz', + enum_type_generateur_ecs_id: 'X1' + }), + // Mixte + libellé + type ECS correspondant identique à l'actuel => pas de réalignement. + genEcs({ + enum_usage_generateur_id: '3', + reference_generateur_mixte: 'MX_X', + enum_type_generateur_ecs_id: 'SAME' + }) + ]; + dpe.logement.installation_ecs_collection.installation_ecs = [ + { + donnee_entree: { enum_type_installation_id: '1' }, + generateur_ecs_collection: { generateur_ecs: generateurs } + } + ]; + + calcul_3cl(dpe, { sanitize: false }); + + // Aucun réalignement effectif. + expect(generateurs[0].donnee_entree.position_volume_chauffe).toBe(1); + expect(generateurs[3].donnee_entree.enum_type_generateur_ecs_id).toBe('X0'); + expect(generateurs[4].donnee_entree.enum_type_generateur_ecs_id).toBe('X1'); + expect(generateurs[5].donnee_entree.enum_type_generateur_ecs_id).toBe('SAME'); + errSpy.mockRestore(); + }); +}); + +describe('get_conso_coeff_1_7_2027', () => { + test('projette la conso EP 2027 pour une maison', () => { + vi.mocked(classe_bilan_dpe).mockReturnValue('C'); + const dpe = makeDpe(); + dpe.logement.sortie = { + ep_conso: { ep_conso_5_usages: 10000 }, + ef_conso: { conso_5_usages: 6000 } + }; + + const res = get_conso_coeff_1_7_2027(dpe); + + // ep = (0.7/0.9) * (10000 - 6000) + 6000 = 9111.11... + // Valeur de référence de régression. + expect(res.ep_conso_5_usages_2027).toBeCloseTo(9111.111111111, 6); + // m2 = floor(9111.11 / 80) = 113 + expect(res.ep_conso_5_usages_m2_2027).toBe(113); + expect(res.classe_bilan_dpe_2027).toBe('C'); + expect(classe_bilan_dpe).toHaveBeenCalledWith(113, 'zc1', 'ca1', 80); + }); + + test("utilise la surface d'immeuble pour un modèle immeuble", () => { + enums.methode_application_dpe_log = { 1: 'dpe immeuble collectif' }; + vi.mocked(classe_bilan_dpe).mockReturnValue('E'); + const dpe = makeDpe(); + dpe.logement.sortie = { + ep_conso: { ep_conso_5_usages: 30000 }, + ef_conso: { conso_5_usages: 12000 } + }; + + const res = get_conso_coeff_1_7_2027(dpe); + + // Sh = 200 => m2 = floor(ep / 200). + const ep = (0.7 / 0.9) * (30000 - 12000) + 12000; + expect(res.ep_conso_5_usages_2027).toBeCloseTo(ep, 6); + expect(classe_bilan_dpe).toHaveBeenCalledWith(Math.floor(ep / 200), 'zc1', 'ca1', 200); + }); +}); diff --git a/src/enums.spec.js b/src/enums.spec.js new file mode 100644 index 00000000..ce588a9b --- /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-generateur.model.js b/src/features/dpe/domain/models/type-generateur.model.js index c96bc48a..b926d951 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 new file mode 100644 index 00000000..51309fd9 --- /dev/null +++ b/src/features/dpe/domain/models/type-generateur.model.spec.js @@ -0,0 +1,25 @@ +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({ + CHAUDIERE: 'CHAUDIERE', + 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)); + }); +}); 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 00000000..f945826a --- /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 00000000..1cf907c9 --- /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 00000000..d2b15944 --- /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/dpe/infrastructure/ch/chTv.store.spec.js b/src/features/dpe/infrastructure/ch/chTv.store.spec.js index 746ea336..6b046876 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 v.enum_type_baie_id.split('|').includes(enumTypeBaieId) && v.enum_type_materiaux_menuiserie_id.split('|').includes(enumTypeMateriauxMenuiserieId) - ) || []; + ) /* c8 ignore next -- .filter renvoie toujours un tableau : fallback défensif inatteignable */ || + []; let ug1, ug2; if (ugValues.length) { [ug1, ug2] = getRange(ug, [...new Set(ugValues.map((value) => value.ug).sort())]); + /* c8 ignore start -- getRange renvoie des bornes issues de ugValues : le find aboutit + toujours et le fallback `|| 0` reste défensif (inatteignable avec les données réelles). */ const uw1 = ugValues.find((value) => value.ug === ug1)?.uw || 0; const uw2 = ugValues.find((value) => value.ug === ug2)?.uw || 0; + /* c8 ignore stop */ const delta_uw = Number(uw2) - Number(uw1); const delta_ug = ug2 - ug1 || 0; @@ -191,7 +195,9 @@ export class BaieVitreeTvStore extends TvStore { */ const deltar = this.getDeltar(enumTypeFermetureId); const uwValues = - tv['ujn'].filter((v) => Number(v.deltar).toPrecision(2) === Number(deltar).toPrecision(2)) || + tv['ujn'].filter( + (v) => Number(v.deltar).toPrecision(2) === Number(deltar).toPrecision(2) + ) /* c8 ignore next -- .filter renvoie toujours un tableau : fallback défensif inatteignable */ || []; let uw1, uw2; @@ -199,12 +205,15 @@ export class BaieVitreeTvStore extends TvStore { if (uwValues.length) { [uw1, uw2] = getRange(uw, [...new Set(uwValues.map((value) => value.uw).sort())]); + /* c8 ignore start -- getRange renvoie des bornes issues de uwValues : le find aboutit + toujours et le fallback `|| 0` reste défensif (inatteignable avec les données réelles). */ const ujn1 = uwValues.find((value) => Number(value.uw).toPrecision(2) === Number(uw1).toPrecision(2)) ?.ujn || 0; const ujn2 = uwValues.find((value) => Number(value.uw).toPrecision(2) === Number(uw2).toPrecision(2)) ?.ujn || 0; + /* c8 ignore stop */ const delta_ujn = Number(ujn2) - Number(ujn1); const delta_uw = uw2 - uw1 || 0; @@ -216,12 +225,15 @@ export class BaieVitreeTvStore extends TvStore { ujn = Number(ujn1) + (delta_ujn * (uw - uw1)) / delta_uw; } + /* c8 ignore start -- les valeurs ujn tabulées sont toujours strictement positives : + ce garde-fou `!ujn` est défensif et inatteignable avec les données réelles. */ if (!ujn) { logger.error( `Pas de valeur forfaitaire ujn pour enumTypeFermetureId:${enumTypeFermetureId}, uw:${uw}` ); return; } + /* c8 ignore stop */ return parseFloat(ujn); } diff --git a/src/features/dpe/infrastructure/enveloppe/baieVitreeTv.store.spec.js b/src/features/dpe/infrastructure/enveloppe/baieVitreeTv.store.spec.js index d03332a2..d705dd05 100755 --- a/src/features/dpe/infrastructure/enveloppe/baieVitreeTv.store.spec.js +++ b/src/features/dpe/infrastructure/enveloppe/baieVitreeTv.store.spec.js @@ -222,6 +222,16 @@ describe('Lecture des tables de valeurs', () => { const ug = tvStore.getSw('0', '0', 0); expect(ug).toBeUndefined(); }); + + test('uw non interpolé lorsque le ug fourni correspond exactement à une valeur tabulée', () => { + // ug passé sous forme de chaîne présente dans la table => getRange renvoie deux + // bornes égales, delta_ug vaut 0 et uw prend directement la valeur tabulée (uw1). + expect(tvStore.getUw('4', '6', '0.5')).toBeCloseTo(1.3, 2); + }); + + test("uw indéfini lorsqu'aucune ligne ne correspond au type de baie / matériau", () => { + expect(tvStore.getUw('9', '99', 2)).toBeUndefined(); + }); }); describe('lecture des valeurs de ujn', () => { @@ -252,6 +262,12 @@ describe('Lecture des tables de valeurs', () => { const ug = tvStore.getUjn('0', 0); expect(ug).toBeUndefined(); }); + + test('ujn non interpolé lorsque le uw fourni correspond exactement à une valeur tabulée', () => { + // uw passé sous forme de chaîne présente dans la table => getRange renvoie deux + // bornes égales, delta_uw vaut 0 et ujn prend directement la valeur tabulée (ujn1). + expect(tvStore.getUjn('2', '0.8')).toBeCloseTo(0.8, 2); + }); }); describe('lecture des valeurs de deltar', () => { diff --git a/src/features/dpe/infrastructure/tv.store.js b/src/features/dpe/infrastructure/tv.store.js index bd469a6a..be133b30 100755 --- a/src/features/dpe/infrastructure/tv.store.js +++ b/src/features/dpe/infrastructure/tv.store.js @@ -3,6 +3,23 @@ import { getRange } from '../../../utils.js'; import { logger } from '../../../core/util/logger/log-service.js'; import { TypeHabitation } from '../domain/models/type-habitation.model.js'; +/** + * Extrait la borne numérique d'un libellé de plage aiu/aue de la table + * `coef_reduction_deperdition`. Les bornes y sont stockées sous forme de libellés + * formatés à la française (ex: `'≤ 6,00'`, `'4,00 <'`) et doivent être converties + * en nombre pour être comparées au ratio surface_aiu / surface_aue. + * + * @param bound {string} Libellé de borne (ex: `'≤ 0,25'`, `'0,25 <'`) + * @return {number} Valeur numérique de la borne + */ +export function parseAiuAueBound(bound) { + return parseFloat( + String(bound) + .replace(/,/g, '.') + .replace(/[^\d.]/g, '') + ); +} + /** * Accès aux données des tables de valeurs * @@ -60,8 +77,8 @@ export class TvStore { !v.zone_climatique || zc.toLowerCase().startsWith(v.zone_climatique.toLowerCase())) && (!rAiuAue || - ((!v.aiu_aue_min || v.aiu_aue_min < rAiuAue) && - (!v.aiu_aue_max || v.aiu_aue_max >= rAiuAue))) + ((!v.aiu_aue_min || parseAiuAueBound(v.aiu_aue_min) < rAiuAue) && + (!v.aiu_aue_max || parseAiuAueBound(v.aiu_aue_max) >= rAiuAue))) )?.b; if (!b) { @@ -208,13 +225,14 @@ export class TvStore { * @return {number|undefined} */ getUeByUpd(enumTypeAdjacenceId, enumPeriodeConstructionId, dsp, upb) { - const ueValues = - tv['ue'].filter( - (v) => - parseInt(v['2s_p']) === dsp && - v.enum_type_adjacence_id.split('|').includes(enumTypeAdjacenceId) && - v.enum_periode_construction_id.split('|').includes(enumPeriodeConstructionId) - ) || []; + const ueFiltered = tv['ue'].filter( + (v) => + parseInt(v['2s_p']) === dsp && + v.enum_type_adjacence_id.split('|').includes(enumTypeAdjacenceId) && + v.enum_periode_construction_id.split('|').includes(enumPeriodeConstructionId) + ); + /* c8 ignore next -- Array.filter renvoie toujours un tableau : repli || [] défensif inatteignable */ + const ueValues = ueFiltered || []; const ueRange = [...new Set(ueValues.map((value) => value.upb).sort())]; let ue; @@ -223,6 +241,7 @@ export class TvStore { if (ueValues.length) { [upb1, upb2] = getRange(upb, ueRange); + /* c8 ignore next 2 -- upb1/upb2 sont toujours des bornes tabulées de ueValues : find() aboutit et ?.ue n'est jamais falsy (replis inatteignables) */ const ue1 = ueValues.find((value) => value.upb === upb1)?.ue || 0; const ue2 = ueValues.find((value) => value.upb === upb2)?.ue || 0; diff --git a/src/features/dpe/infrastructure/tv.store.spec.js b/src/features/dpe/infrastructure/tv.store.spec.js index f334fc22..eef4c5ca 100755 --- a/src/features/dpe/infrastructure/tv.store.spec.js +++ b/src/features/dpe/infrastructure/tv.store.spec.js @@ -11,6 +11,23 @@ describe('Lecture des tables de valeurs', () => { tvStore = new TvStore(); }); + describe('lecture des valeurs de uPorte', () => { + test.each([ + { + label: 'porte simple bois/PVC opaque pleine (id partagé 1|5)', + enumTypePorteId: '1', + expected: 3.5 + }, + { label: 'porte simple métal opaque pleine', enumTypePorteId: '9', expected: 5.8 } + ])('uPorte pour $label', ({ enumTypePorteId, expected }) => { + expect(tvStore.getUPorte(enumTypePorteId)).toBe(expected); + }); + + test('pas de valeur de uPorte', () => { + expect(tvStore.getUPorte('0')).toBeUndefined(); + }); + }); + describe('lecture des valeurs de b', () => { test.each([ { enumTypeAdjacenceId: '1', label: 'extérieur', bExpected: 1 }, @@ -56,6 +73,26 @@ describe('Lecture des tables de valeurs', () => { expect(b).toBe(bExpected); } ); + + test('pas de valeur de b', () => { + // Aucune adjacence '0' dans la table : b introuvable -> undefined. + expect(tvStore.getB('0')).toBeUndefined(); + }); + + test('b filtré par zone climatique (espace tampon solarisé)', () => { + // Adjacence 10 : les lignes portent une zone_climatique -> exerce le filtre + // zc.toLowerCase().startsWith(v.zone_climatique.toLowerCase()) (H1 <- h1a). + expect(tvStore.getB('10', undefined, '6', undefined, 'h1a')).toBe(0.95); + }); + + test.each([ + // rAiuAue au-dessus d'une borne min : exerce parseAiuAueBound(aiu_aue_min) < rAiuAue. + { label: 'ratio dans un intervalle borné (min et max)', rAiuAue: 0.3, expected: 0.95 }, + // rAiuAue très élevé : dernière ligne sans aiu_aue_max -> exerce la branche !v.aiu_aue_max. + { label: 'ratio au-dessus de la dernière borne (sans max)', rAiuAue: 60, expected: 0.2 } + ])('b filtré par le ratio aiu/aue pour $label', ({ rAiuAue, expected }) => { + expect(tvStore.getB('8', 3, '4', rAiuAue)).toBe(expected); + }); }); describe('lecture des valeurs de uVue', () => { @@ -375,6 +412,13 @@ describe('Lecture des tables de valeurs', () => { } ); + test('ue sans interpolation quand upb correspond exactement à une borne tabulée', () => { + // upb fourni comme borne exacte de la table ('0.83') : getRange renvoie deux bornes + // identiques, delta_upb vaut 0 et la valeur tabulée est retournée sans interpolation. + const ue = tvStore.getUeByUpd('3', '1', 4, '0.83'); + expect(ue).toBe(0.37); + }); + test('pas de valeur de ue', () => { const ue = tvStore.getUeByUpd('0', '1', 0, 0); expect(ue).toBeUndefined(); 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 69363d77..06cc0b56 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/besoin-ch.service.spec.js b/src/features/engine/domain/apport_et_besoin/ch/besoin-ch.service.spec.js index c2eb6a52..2f047530 100755 --- a/src/features/engine/domain/apport_et_besoin/ch/besoin-ch.service.spec.js +++ b/src/features/engine/domain/apport_et_besoin/ch/besoin-ch.service.spec.js @@ -50,6 +50,24 @@ describe('Calcul des besoins en chauffage du logement', () => { } ); + test('Fraction des apports gratuits nulle quand les degrés-heures de chauffage sont nuls', () => { + // dh === 0 : aucun besoin de chauffage sur le mois, la fraction est court-circuitée à 0 + // (évite aussi la division par zéro dans le calcul de Xj). + /** @type {Contexte} */ + const ctx = { inertie: { id: 1 } }; + + /** @type { Logement } **/ + const logement = { + donnees_de_calcul: { + apportsInterneCh: { Janvier: 102.5 }, + apportsSolaire: { Janvier: 12.5 } + }, + sortie: { deperdition: { deperdition_enveloppe: 25.9 } } + }; + + expect(service.fractionBesoinCh(ctx, logement, 'Janvier', 0)).toBe(0); + }); + test('Besoin de chauffage hors pertes récupérées et fraction des apports gratuits pour un mois', () => { /** @type {Contexte} */ const ctx = { inertie: { id: 1 } }; 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 bbeddc93..f59df311 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,38 @@ describe('Calcul des pertes de génération de chauffage récupérées', () => { } }); - describe("Test d'intégration des installations CH", () => { + test('Qrec vaut 0 pour un usage de générateur inconnu (aucune durée de récupération)', () => { + // enum_usage_generateur_id hors {1, 2, 3} : le switch ne définit pas Dperj (undefined), + // le produit devient NaN et la garde `|| 0` ramène la contribution à 0. + /** @type {GenerateurChauffage[]} */ + const generateurs = [ + { + donnee_entree: { presence_ventouse: 0, enum_usage_generateur_id: 99 }, + donnee_intermediaire: { pn: 25000, qp0: 125 } + } + ]; + + expect(service.Qrec(generateurs, 12.5, 1532.9)).toBe(0); + }); + + test('Conversion en kWh des pertes de génération récupérées (execute)', () => { + // On isole execute de la formule mensuelle : pertesGenerateurChRecup est doublée pour + // ne vérifier que la conversion Wh -> kWh (division par 1000) et le mapping des 2 champs. + vi.spyOn(service, 'pertesGenerateurChRecup').mockImplementation((ctx, logement, depensier) => + depensier ? 6000 : 3000 + ); + + const res = service.execute({}, {}); + + expect(res).toStrictEqual({ + pertes_generateur_ch_recup: 3, + pertes_generateur_ch_recup_depensier: 6 + }); + expect(service.pertesGenerateurChRecup).toHaveBeenCalledWith({}, {}, false); + expect(service.pertesGenerateurChRecup).toHaveBeenCalledWith({}, {}, true); + }); + + 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 9949841a..0911e47e 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,26 @@ 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", () => { + test("Sommation du besoin ECS sur les douze mois de l'année", () => { + // On isole execute de la formule mensuelle : besoinEcsMois est doublé pour ne mesurer + // que la logique d'agrégation (une valeur conventionnelle, une valeur dépensière par mois). + vi.spyOn(service, 'besoinEcsMois').mockImplementation((ctx, mois, depensier) => + depensier ? 10 : 1 + ); + + /** @type {Contexte} */ + const ctx = { altitude: { value: '400-800m' }, zoneClimatique: { value: 'h1a' }, nadeq: 2 }; + + const res = service.execute(ctx); + + // 12 mois : conventionnel = 12 * 1, dépensier = 12 * 10 + expect(res).toStrictEqual({ besoin_ecs: 12, besoin_ecs_depensier: 120 }); + expect(service.besoinEcsMois).toHaveBeenCalledTimes(24); + expect(service.besoinEcsMois).toHaveBeenCalledWith(ctx, 'Janvier', false); + expect(service.besoinEcsMois).toHaveBeenCalledWith(ctx, 'Janvier', true); + }); + + 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 f6dc26dd..ec768810 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,46 @@ describe('Calcul des pertes de distribution et stockage récupérées', () => { } }); - describe("Test d'intégration des installations ECS", () => { + test('Agrégation et conversion en kWh des pertes ECS récupérées (execute)', () => { + // On isole execute des formules mensuelles : les deux méthodes de calcul sont doublées + // pour ne vérifier que la conversion Wh -> kWh (division par 1000) et le mapping des 4 champs. + vi.spyOn(service, 'pertesDistributionEcsRecup').mockImplementation( + (ctx, logement, depensier) => (depensier ? 2000 : 1000) + ); + vi.spyOn(service, 'pertesStockageEcsRecup').mockImplementation((ctx, logement, depensier) => + depensier ? 4000 : 3000 + ); + + const res = service.execute({}, {}); + + expect(res).toStrictEqual({ + pertes_distribution_ecs_recup: 1, + pertes_distribution_ecs_recup_depensier: 2, + pertes_stockage_ecs_recup: 3, + pertes_stockage_ecs_recup_depensier: 4 + }); + expect(service.pertesDistributionEcsRecup).toHaveBeenCalledWith({}, {}, false); + expect(service.pertesDistributionEcsRecup).toHaveBeenCalledWith({}, {}, true); + expect(service.pertesStockageEcsRecup).toHaveBeenCalledWith({}, {}, false); + expect(service.pertesStockageEcsRecup).toHaveBeenCalledWith({}, {}, true); + }); + + test("Pertes nulles en l'absence d'installation ECS", () => { + // Valeur de repli [] quand installation_ecs_collection est absente : aucune installation à sommer. + vi.spyOn(tvStore, 'getData').mockReturnValue(1); + + /** @type {Contexte} */ + const ctx = { + altitude: { value: '400-800m' }, + zoneClimatique: { value: 'h1a' }, + inertie: { ilpa: 0 } + }; + + expect(service.pertesDistributionEcsRecup(ctx, {}, false)).toBe(0); + expect(service.pertesStockageEcsRecup(ctx, {}, false)).toBe(0); + }); + + 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 946f58d6..f6fd7869 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,49 @@ describe('Calcul des besoins en froid du logement', () => { ); }); - describe("Test d'intégration pour le besoin en froid", () => { + test('Sommation du besoin en froid sur les douze mois en présence de climatisation', () => { + // Climatisation présente : execute doit agréger besoinFrMois sur les 12 mois. + // besoinFrMois est doublé pour n'évaluer que la logique d'agrégation. + vi.spyOn(service, 'besoinFrMois').mockImplementation((ctx, logement, mois, depensier) => + depensier ? 5 : 2 + ); + + /** @type {Contexte} */ + const ctx = { zoneClimatique: { id: 1 } }; + + /** @type {Logement} */ + const logement = { climatisation_collection: { climatisation: [{}] } }; + + const res = service.execute(ctx, logement); + + expect(res).toStrictEqual({ besoin_fr: 24, besoin_fr_depensier: 60 }); + expect(service.besoinFrMois).toHaveBeenCalledTimes(24); + }); + + test('Facteur d’utilisation lorsque le ratio de bilan thermique Rbth vaut exactement 1', () => { + // Branche fut = a / (a + 1) : atteinte uniquement quand Rbth === 1. + // Rbth = (aijFr + asjFr) / (GV * (tempExtMoyClim - 28) * nref) + // = 6000 / (100 * (30 - 28) * 30) = 1 + vi.spyOn(tvStore, 'getData').mockReturnValue(30); + vi.spyOn(apportGratuitService, 'apportInterneMois').mockReturnValue(6000); + vi.spyOn(apportGratuitService, 'apportSolaireMois').mockReturnValue(0); + + /** @type {Logement} **/ + const logement = { enveloppe: {}, sortie: { deperdition: { deperdition_enveloppe: 100 } } }; + + /** @type {Contexte} */ + const ctx = { + altitude: { value: '400-800m' }, + zoneClimatique: { value: 'h1a' }, + inertie: { id: 4 }, + surfaceHabitable: 25 + }; + + // Valeur de référence de régression (module réel exécuté avec ces entrées) + expect(service.besoinFrMois(ctx, logement, 'Janvier', false)).toBeCloseTo(9.608856088560886, 9); + }); + + 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 3e5ac7d2..a7c4fc10 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.js b/src/features/engine/domain/ch/generateur-ch.service.js index eede2e73..bb729195 100755 --- a/src/features/engine/domain/ch/generateur-ch.service.js +++ b/src/features/engine/domain/ch/generateur-ch.service.js @@ -79,6 +79,8 @@ export class GenerateurChService { generateurChauffage.donnee_utilisateur.generateur = this.#chTvStore.getGenerateurCombustion( generateurChauffageDE.enum_type_generateur_ch_id, generateurChauffage.donnee_intermediaire.pn / + /* c8 ignore next 2 -- repli défensif inatteignable : donnee_utilisateur.ratio_virtualisation + est déjà défini juste au-dessus (valeur de l'installation ou 1), donc toujours truthy ici. */ ((generateurChauffage.donnee_utilisateur.ratio_virtualisation || 1) * 1000) ); 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 3805b602..ed5eb25c 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,121 @@ describe('Calcul des caractéristiques des générateurs de chauffage', () => { }); }); - describe("Test d'intégration des installations de chauffage", () => { + test('Générateur à combustion sans pn : calcul de la puissance nominale et filtrage des émetteurs', () => { + // Type 76 (chaudière fioul) : générateur à combustion ET chaudière, pour que le calcul + // des températures de fonctionnement (utilisant les émetteurs filtrés) soit exécuté. + vi.spyOn(chTvStore, 'getCombustionGenerateurs').mockReturnValue([76]); + vi.spyOn(chTvStore, 'getPacGenerateurs').mockReturnValue([2]); + vi.spyOn(chTvStore, 'getGenerateurCombustion').mockReturnValue({}); + // pn absent dans donnee_intermediaire -> execute doit appeler this.pn(ctx, logement). + const pnSpy = vi.spyOn(service, 'pn').mockReturnValue(20000); + vi.spyOn(emetteurChService, 'temperatureFonctionnement').mockReturnValue({ + temp_fonc_30: 18, + temp_fonc_100: 25 + }); + + /** @type {GenerateurChauffage} */ + const generateurChauffage = { + donnee_entree: { + enum_methode_saisie_carac_sys_id: 4, + enum_type_generateur_ch_id: 76, + enum_lien_generateur_emetteur_id: 'L1', + presence_ventouse: 1 + }, + // pas de pn : oblige au calcul de la puissance nominale + donnee_intermediaire: { rpn: 0.95, rpint: 0.68, qp0: 125 } + }; + + // Deux émetteurs : un seul est lié au générateur (même enum_lien_generateur_emetteur_id). + const emetteurLie = { + donnee_entree: { enum_lien_generateur_emetteur_id: 'L1', surface_chauffee: 80 }, + donnee_intermediaire: { rendement_emission: 0.86 } + }; + const emetteurNonLie = { + donnee_entree: { enum_lien_generateur_emetteur_id: 'L2', surface_chauffee: 40 }, + donnee_intermediaire: { rendement_emission: 0.9 } + }; + + const installationChauffage = { + donnee_entree: { ratio_virtualisation: 1 }, + generateur_chauffage_collection: { generateur_chauffage: [generateurChauffage] }, + emetteur_chauffage_collection: { emetteur_chauffage: [emetteurLie, emetteurNonLie] } + }; + + /** @type {Logement} */ + const logement = { + installation_chauffage_collection: { installation_chauffage: [installationChauffage] }, + sortie: { deperdition: { deperdition_enveloppe: 300 } } + }; + + /** @type {Contexte} */ + const ctx = { altitude: { id: 1 }, zoneClimatique: { id: 1 } }; + + service.execute(ctx, logement, installationChauffage); + + // La puissance nominale a été calculée puis affectée + expect(pnSpy).toHaveBeenCalledWith(ctx, logement); + expect(generateurChauffage.donnee_intermediaire.pn).toBe(20000); + + // getGenerateurCombustion utilise pn / (ratio * 1000) = 20000 / 1000 = 20 + expect(chTvStore.getGenerateurCombustion).toHaveBeenCalledWith(76, 20); + + // Seul l'émetteur lié est transmis au calcul des températures de fonctionnement + expect(emetteurChService.temperatureFonctionnement).toHaveBeenCalledWith( + ctx, + generateurChauffage.donnee_entree, + [emetteurLie] + ); + }); + + test('execute : installation sans collection de générateurs ne fait rien', () => { + // Valeur de repli [] quand generateur_chauffage_collection est absente. + const combustionSpy = vi.spyOn(chTvStore, 'getCombustionGenerateurs'); + + /** @type {Contexte} */ + const ctx = { zoneClimatique: { id: 1 } }; + + expect(() => service.execute(ctx, {}, { donnee_entree: {} })).not.toThrow(); + expect(combustionSpy).not.toHaveBeenCalled(); + }); + + test('execute : ratio de virtualisation par défaut à 1 quand non renseigné', () => { + vi.spyOn(chTvStore, 'getCombustionGenerateurs').mockReturnValue([1]); + vi.spyOn(chTvStore, 'getPacGenerateurs').mockReturnValue([2]); + vi.spyOn(chTvStore, 'getGenerateurCombustion').mockReturnValue({}); + + /** @type {GenerateurChauffage} */ + const generateurChauffage = { + donnee_entree: { + enum_methode_saisie_carac_sys_id: 4, + enum_type_generateur_ch_id: 1, + presence_ventouse: 1 + }, + donnee_intermediaire: { rpn: 0.95, rpint: 0.68, qp0: 125, pn: 15000 } + }; + + const installationChauffage = { + // donnee_entree sans ratio_virtualisation : repli sur 1 + donnee_entree: {}, + generateur_chauffage_collection: { generateur_chauffage: [generateurChauffage] } + }; + + /** @type {Logement} */ + const logement = { + installation_chauffage_collection: { installation_chauffage: [installationChauffage] } + }; + + /** @type {Contexte} */ + const ctx = { zoneClimatique: { id: 1 } }; + + service.execute(ctx, logement, installationChauffage); + + expect(generateurChauffage.donnee_utilisateur.ratio_virtualisation).toBe(1); + // pn / (ratio * 1000) = 15000 / 1000 = 15 + expect(chTvStore.getGenerateurCombustion).toHaveBeenCalledWith(1, 15); + }); + + 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/ch/installation-ch.service.spec.js b/src/features/engine/domain/ch/installation-ch.service.spec.js index 953c2d15..b28fcbdf 100755 --- a/src/features/engine/domain/ch/installation-ch.service.spec.js +++ b/src/features/engine/domain/ch/installation-ch.service.spec.js @@ -38,4 +38,18 @@ describe('Calcul des installations de chauffage', () => { service.execute(ctx, logement); expect(generateurChService.execute).toHaveBeenCalledWith(ctx, logement, installationCh); }); + + test("Aucun générateur traité en l'absence d'installation de chauffage", () => { + vi.spyOn(generateurChService, 'execute').mockReturnThis(); + + /** @type {Contexte} */ + const ctx = { zoneClimatique: { id: 1 } }; + + // Collection absente : la valeur de repli [] doit être utilisée (aucun appel au service générateur) + service.execute(ctx, {}); + // Collection présente mais sans tableau d'installations + service.execute(ctx, { installation_chauffage_collection: {} }); + + expect(generateurChService.execute).not.toHaveBeenCalled(); + }); }); 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 411d519d..d06fea75 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,87 @@ describe('Calcul des consos en froid du logement', () => { } ); - describe("Test d'intégration pour le besoin en froid", () => { + test('consoFroid : données intermédiaires absentes -> EER lu dans la table (repli {})', () => { + // climatisation.donnee_intermediaire absente : repli sur {} puis lecture de l'EER forfaitaire. + vi.spyOn(tvStore, 'getEer').mockReturnValue(90); + + /** @type {Contexte} */ + const contexte = { zoneClimatique: { id: 1 }, surfaceHabitable: 100 }; + + /** @type {Climatisation} */ + const climatisation = { + donnee_entree: { enum_methode_saisie_carac_sys_id: 6, enum_periode_installation_fr_id: 2 } + }; + + /** @type {ApportEtBesoin} */ + const apportEtBesoin = { besoin_fr: 1000, besoin_fr_depensier: 1500 }; + + const consoFroid = service.consoFroid(contexte, apportEtBesoin, climatisation); + + expect(tvStore.getEer).toHaveBeenCalledWith(1, 2); + // conso = 0.9 * besoin / eer + expect(consoFroid.conso_fr).toBeCloseTo((0.9 * 1000) / 90, 5); + expect(consoFroid.conso_fr_depensier).toBeCloseTo((0.9 * 1500) / 90, 5); + }); + + test('execute : renseigne les données intermédiaires de chaque climatisation', () => { + // On isole execute du calcul détaillé : consoFroid est doublée pour ne vérifier que + // l'itération sur les climatisations et l'affectation des champs (dont l'initialisation ??=). + vi.spyOn(service, 'consoFroid').mockReturnValue({ + besoin_fr: 10, + besoin_fr_depensier: 15, + conso_fr: 2, + conso_fr_depensier: 3 + }); + + /** @type {Contexte} */ + const ctx = { zoneClimatique: { id: 1 }, surfaceHabitable: 100 }; + + // Première climatisation sans donnee_intermediaire (branche ??= {}), + // seconde avec un objet préexistant (branche ??= conservée). + const climSansDI = { donnee_entree: {} }; + const climAvecDI = { donnee_entree: {}, donnee_intermediaire: { eer: 42 } }; + + /** @type {Logement} */ + const logement = { + sortie: { apport_et_besoin: { besoin_fr: 1000, besoin_fr_depensier: 1500 } }, + climatisation_collection: { climatisation: [climSansDI, climAvecDI] } + }; + + service.execute(ctx, logement); + + expect(service.consoFroid).toHaveBeenCalledTimes(2); + expect(service.consoFroid).toHaveBeenCalledWith( + ctx, + logement.sortie.apport_et_besoin, + climSansDI + ); + + for (const clim of [climSansDI, climAvecDI]) { + expect(clim.donnee_intermediaire).toMatchObject({ + besoin_fr: 10, + besoin_fr_depensier: 15, + conso_fr: 2, + conso_fr_depensier: 3 + }); + } + // L'objet préexistant a été conservé (eer non écrasé par ??=) + expect(climAvecDI.donnee_intermediaire.eer).toBe(42); + }); + + test("execute : aucune conso calculée en l'absence de climatisation", () => { + // Valeur de repli [] quand climatisation_collection est absente. + vi.spyOn(service, 'consoFroid'); + + /** @type {Contexte} */ + const ctx = { zoneClimatique: { id: 1 }, surfaceHabitable: 100 }; + + service.execute(ctx, { sortie: { apport_et_besoin: {} } }); + + expect(service.consoFroid).not.toHaveBeenCalled(); + }); + + 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/constants.spec.js b/src/features/engine/domain/constants.spec.js new file mode 100644 index 00000000..adc68c91 --- /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/contexte.builder.spec.js b/src/features/engine/domain/contexte.builder.spec.js index 5b0b0b07..dabf8b77 100755 --- a/src/features/engine/domain/contexte.builder.spec.js +++ b/src/features/engine/domain/contexte.builder.spec.js @@ -160,6 +160,27 @@ describe('Generateur du contexte du calcul', () => { }); }); + test("DPE appartement généré à partir des données d'un DPE immeuble", () => { + // Méthode 10 : appartement issu des données immeuble. + // -> typeDpe APPARTEMENT_A_PARTIR_IMMEUBLE et surface habitable = celle de l'immeuble. + const dpe = { + logement: { + meteo: {}, + caracteristique_generale: { + enum_methode_application_dpe_log_id: '10', + surface_habitable_logement: 48.9, + surface_habitable_immeuble: 105 + } + } + }; + + expect(contexteBuilder.fromDpe(dpe)).toMatchObject({ + typeHabitation: TypeHabitation.APPARTEMENT, + typeDpe: TypeDpe.APPARTEMENT_A_PARTIR_IMMEUBLE, + surfaceHabitable: 105 + }); + }); + test('Contexte avec calcul ilpa', () => { const dpe = { logement: { diff --git a/src/features/engine/domain/ecs/generateur-ecs.service.spec.js b/src/features/engine/domain/ecs/generateur-ecs.service.spec.js index 918f125f..b89104ad 100755 --- a/src/features/engine/domain/ecs/generateur-ecs.service.spec.js +++ b/src/features/engine/domain/ecs/generateur-ecs.service.spec.js @@ -121,6 +121,17 @@ describe('Calcul des informations techniques des générateurs ECS', () => { }); }); + test("execute ne fait rien quand l'installation n'a pas de collection de générateurs", () => { + // Valeur de repli [] quand generateur_ecs_collection est absente : aucune mutation. + const pertesStockageSpy = vi.spyOn(service, 'pertesStockage'); + + /** @type {InstallationEcs} */ + const installationEcs = {}; + expect(() => service.execute(installationEcs)).not.toThrow(); + expect(installationEcs).toStrictEqual({}); + expect(pertesStockageSpy).not.toHaveBeenCalled(); + }); + test("Determination du type d'énergie électrique du générateur", () => { vi.spyOn(tvStore, 'getElectriqueEcsGenerateurs').mockReturnValue([1]); expect(service.generateurElectrique({ enum_type_generateur_ecs_id: 1 })).toBeTruthy(); 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 5b23a624..73fb9d6a 100755 --- a/src/features/engine/domain/ecs/installation-ecs.service.spec.js +++ b/src/features/engine/domain/ecs/installation-ecs.service.spec.js @@ -1,10 +1,11 @@ -import { beforeEach, describe, expect, test } from 'vitest'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; import { GenerateurEcsService } from './generateur-ecs.service.js'; import { InstallationEcsService } from './installation-ecs.service.js'; 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,59 @@ 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", () => { + test('execute : parcourt les installations ECS et renseigne besoins et pertes', () => { + // Le calcul des Qgw par générateur est délégué au GenerateurEcsService : on le double + // pour isoler la logique d'orchestration de l'installation. + vi.spyOn(generateurEcsService, 'execute').mockReturnValue(undefined); + + /** @type {Contexte} */ + const ctx = { surfaceHabitable: 100 }; + + /** @type {InstallationEcs} */ + const installationEcs = { + donnee_entree: { enum_type_installation_id: 1, surface_habitable: 100 }, + generateur_ecs_collection: { + generateur_ecs: [ + { + donnee_entree: { position_volume_chauffe_stockage: 1 }, + donnee_utilisateur: { Qgw: 500 } + } + ] + } + }; + + /** @type {Logement} */ + const logement = { installation_ecs_collection: { installation_ecs: [installationEcs] } }; + + service.execute(ctx, logement, { besoin_ecs: 1000, besoin_ecs_depensier: 1500 }); + + // Délégation du calcul par générateur + expect(generateurEcsService.execute).toHaveBeenCalledWith(installationEcs); + + // Besoins proratisés (surface installation === surface logement -> ratio 1) + expect(installationEcs.donnee_intermediaire).toStrictEqual({ + besoin_ecs: 1000, + besoin_ecs_depensier: 1500 + }); + + // Pertes de distribution individuelle : 0.1 * besoin (en Wh) ; installation individuelle -> QdwCol* = 0 + expect(installationEcs.donnee_utilisateur.QdwIndVc.conventionnel).toBeCloseTo(100000, 5); + expect(installationEcs.donnee_utilisateur.QdwIndVc.depensier).toBeCloseTo(150000, 5); + expect(installationEcs.donnee_utilisateur.QdwColVc.conventionnel).toBe(0); + // Générateur en volume chauffé -> stockage récupérable = Qgw + expect(installationEcs.donnee_utilisateur.QgwRecuperable).toBe(500); + }); + + test('execute : aucune installation traitée quand la collection ECS est absente', () => { + // Valeur de repli [] quand installation_ecs_collection est absente. + const generateurSpy = vi.spyOn(generateurEcsService, 'execute'); + + service.execute({ surfaceHabitable: 100 }, {}, { besoin_ecs: 0, besoin_ecs_depensier: 0 }); + + expect(generateurSpy).not.toHaveBeenCalled(); + }); + + 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/engine.service.spec.js b/src/features/engine/domain/engine.service.spec.js index 02b0c369..eac32438 100755 --- a/src/features/engine/domain/engine.service.spec.js +++ b/src/features/engine/domain/engine.service.spec.js @@ -1,7 +1,230 @@ -import { describe, test } from 'vitest'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { EngineService } from './engine.service.js'; +import { logger } from '../../../core/util/logger/log-service.js'; -describe.skip('Application de la méthode 3CL', () => { - test('test complet de calcul (validation ADEME?)', () => { - // const dpe = EngineService.execute(originalDpe);= +/** + * Tests unitaires isolés de `EngineService`. + * + * Les quatre services injectés (`DeperditionEnveloppeService`, + * `ApportEtBesoinService`, `ConsoService` et `ContexteBuilder`) sont remplacés + * par des doubles de test (spies `vi.fn`) afin de n'exercer que la logique + * d'orchestration du moteur : nettoyage des données calculées, construction du + * contexte, délégation aux services et agrégation des sorties. Aucune donnée + * réelle (tables, DPE complet) n'est nécessaire. + */ + +/** @type {{ deperditions: import('vitest').Mock }} */ +let deperditionService; + +/** @type {{ execute: import('vitest').Mock }} */ +let apportEtBesoinService; + +/** @type {{ execute: import('vitest').Mock }} */ +let consoService; + +/** @type {{ fromDpe: import('vitest').Mock }} */ +let contextBuilder; + +/** @type {EngineService} */ +let service; + +/** + * Construit une enveloppe minimale : toutes les collections existent mais leurs + * tableaux internes sont absents (exerce le court-circuit des `?.`). + */ +function enveloppeMinimale() { + return { + mur_collection: {}, + baie_vitree_collection: {}, + ets_collection: {}, + plancher_bas_collection: {}, + plancher_haut_collection: {}, + pont_thermique_collection: {}, + porte_collection: {} + }; +} + +/** + * Construit un DPE minimal : collections présentes mais vides. Utilisé pour + * exercer les branches « tableau absent » de `#removeComputedData`. + */ +function dpeMinimal() { + return { + numero_dpe: 'DPE-MIN', + logement: { + caracteristique_generale: {}, + enveloppe: enveloppeMinimale(), + climatisation_collection: {}, + ventilation_collection: {}, + installation_ecs_collection: {}, + installation_chauffage_collection: {} + } + }; +} + +/** + * Construit un DPE complet : chaque collection possède un tableau peuplé avec + * des `donnee_intermediaire`. Exerce la branche « tableau présent » de + * `#removeComputedData` ainsi que les `?.` imbriqués. + */ +function dpeComplet() { + return { + numero_dpe: 'DPE-FULL', + logement: { + caracteristique_generale: {}, + sortie: { deperdition: 'ancienne valeur' }, + enveloppe: { + mur_collection: { mur: [{ donnee_intermediaire: {} }] }, + baie_vitree_collection: { + baie_vitree: [ + { + donnee_intermediaire: {}, + baie_vitree_double_fenetre: { donnee_intermediaire: {} } + }, + // baie sans double fenêtre : exerce la branche falsy du `?.` + { donnee_intermediaire: {} } + ] + }, + ets_collection: { ets: { donnee_intermediaire: {} } }, + plancher_bas_collection: { plancher_bas: [{ donnee_intermediaire: {} }] }, + plancher_haut_collection: { plancher_haut: [{ donnee_intermediaire: {} }] }, + pont_thermique_collection: { pont_thermique: [{ donnee_intermediaire: {} }] }, + porte_collection: { porte: [{ donnee_intermediaire: {} }] } + }, + climatisation_collection: { climatisation: [{ donnee_intermediaire: {} }] }, + ventilation_collection: { ventilation: [{ donnee_intermediaire: {} }] }, + installation_ecs_collection: { + installation_ecs: [ + { + donnee_intermediaire: {}, + generateur_ecs_collection: { + generateur_ecs: [{ donnee_intermediaire: {} }] + } + } + ] + }, + installation_chauffage_collection: { + installation_chauffage: [ + { + donnee_intermediaire: {}, + emetteur_chauffage_collection: { + emetteur_chauffage: [{ donnee_intermediaire: {} }] + } + } + ] + }, + production_elec_enr: { donnee_intermediaire: {} } + } + }; +} + +describe('EngineService (orchestration de la méthode 3CL)', () => { + beforeEach(() => { + deperditionService = { deperditions: vi.fn().mockReturnValue('DEPERDITION') }; + apportEtBesoinService = { execute: vi.fn().mockReturnValue('APPORT_ET_BESOIN') }; + consoService = { execute: vi.fn() }; + contextBuilder = { fromDpe: vi.fn().mockReturnValue('CTX') }; + service = new EngineService( + deperditionService, + apportEtBesoinService, + consoService, + contextBuilder + ); + vi.spyOn(logger, 'info').mockImplementation(() => {}); + }); + + test('trace le numéro de DPE traité', () => { + service.execute(dpeMinimal()); + expect(logger.info).toHaveBeenCalledWith('Process DPE DPE-MIN'); + }); + + test('construit le contexte à partir du DPE nettoyé puis délègue aux services', () => { + const resultat = service.execute(dpeMinimal()); + + // Le contexte est construit une fois à partir du DPE traité + expect(contextBuilder.fromDpe).toHaveBeenCalledTimes(1); + const dpeTraite = contextBuilder.fromDpe.mock.calls[0][0]; + + // Les services reçoivent le contexte construit et le logement traité + expect(deperditionService.deperditions).toHaveBeenCalledWith('CTX', dpeTraite.logement); + expect(apportEtBesoinService.execute).toHaveBeenCalledWith('CTX', dpeTraite.logement); + expect(consoService.execute).toHaveBeenCalledWith('CTX', dpeTraite.logement); + + // Les résultats des services sont agrégés dans la sortie + expect(resultat.logement.sortie.deperdition).toBe('DEPERDITION'); + expect(resultat.logement.sortie.apport_et_besoin).toBe('APPORT_ET_BESOIN'); + }); + + test('initialise la structure de sortie avec toutes les clés attendues', () => { + const resultat = service.execute(dpeMinimal()); + + expect(resultat.logement.sortie).toEqual({ + deperdition: 'DEPERDITION', + apport_et_besoin: 'APPORT_ET_BESOIN', + ef_conso: undefined, + ep_conso: undefined, + emission_ges: undefined, + cout: undefined, + production_electricite: undefined, + sortie_par_energie_collection: undefined, + confort_ete: undefined, + qualite_isolation: undefined + }); + }); + + test('supprime les données de calcul intermédiaires du DPE retourné', () => { + const resultat = service.execute(dpeMinimal()); + expect(resultat.logement.donnees_de_calcul).toBeUndefined(); + }); + + test("n'altère pas le DPE d'entrée (travail sur une copie profonde)", () => { + const original = dpeComplet(); + const resultat = service.execute(original); + + // L'entrée conserve ses données calculées d'origine + expect(original.logement.sortie).toEqual({ deperdition: 'ancienne valeur' }); + expect(original.logement.enveloppe.mur_collection.mur[0].donnee_intermediaire).toBeDefined(); + // La sortie est bien un nouvel objet + expect(resultat).not.toBe(original); + }); + + test('nettoie toutes les données intermédiaires quand les collections sont peuplées', () => { + const resultat = service.execute(dpeComplet()); + const env = resultat.logement.enveloppe; + + expect(env.mur_collection.mur[0].donnee_intermediaire).toBeUndefined(); + expect(env.baie_vitree_collection.baie_vitree[0].donnee_intermediaire).toBeUndefined(); + expect( + env.baie_vitree_collection.baie_vitree[0].baie_vitree_double_fenetre.donnee_intermediaire + ).toBeUndefined(); + expect(env.ets_collection.ets.donnee_intermediaire).toBeUndefined(); + expect(env.plancher_bas_collection.plancher_bas[0].donnee_intermediaire).toBeUndefined(); + expect(env.plancher_haut_collection.plancher_haut[0].donnee_intermediaire).toBeUndefined(); + expect(env.pont_thermique_collection.pont_thermique[0].donnee_intermediaire).toBeUndefined(); + expect(env.porte_collection.porte[0].donnee_intermediaire).toBeUndefined(); + + expect( + resultat.logement.climatisation_collection.climatisation[0].donnee_intermediaire + ).toBeUndefined(); + expect( + resultat.logement.ventilation_collection.ventilation[0].donnee_intermediaire + ).toBeUndefined(); + + const ecs = resultat.logement.installation_ecs_collection.installation_ecs[0]; + expect(ecs.donnee_intermediaire).toBeUndefined(); + expect(ecs.generateur_ecs_collection.generateur_ecs[0].donnee_intermediaire).toBeUndefined(); + + const ch = resultat.logement.installation_chauffage_collection.installation_chauffage[0]; + expect(ch.donnee_intermediaire).toBeUndefined(); + expect( + ch.emetteur_chauffage_collection.emetteur_chauffage[0].donnee_intermediaire + ).toBeUndefined(); + + expect(resultat.logement.production_elec_enr.donnee_intermediaire).toBeUndefined(); + }); + + test('reste robuste lorsque les collections ne contiennent aucun élément', () => { + // Aucun tableau interne ni production_elec_enr : les `?.` court-circuitent + expect(() => service.execute(dpeMinimal())).not.toThrow(); }); }); 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 f3ffdd0b..b3a3ff57 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; @@ -117,6 +118,32 @@ describe('Calcul de déperdition des baies vitrées', () => { expect(tvStore.getSw).toHaveBeenNthCalledWith(2, '2', '6', '4', '1', '3'); expect(sw).toBe(18.8 * 18.8); }); + + test('Doit retomber sur un facteur 1 si le sw de la double-fenêtre est introuvable', () => { + // sw de la baie principale saisi, mais celui de la double-fenêtre indéterminé + // (getSw indéfini) => le facteur solaire équivalent reste celui de la baie principale. + vi.spyOn(tvStore, 'getSw').mockReturnValue(undefined); + /** + * @type {BaieVitree} + */ + let bv = { + donnee_entree: { + sw_saisi: 0.5, + double_fenetre: 1 + }, + baie_vitree_double_fenetre: { + donnee_entree: { + enum_type_materiaux_menuiserie_id: '4', + enum_type_vitrage_id: '2', + enum_type_baie_id: '6' + } + } + }; + + let sw = service.sw(bv); + expect(tvStore.getSw).toHaveBeenCalledOnce(); + expect(sw).toBe(0.5); + }); }); describe('Determination du coefficient uw', () => { @@ -166,6 +193,30 @@ describe('Calcul de déperdition des baies vitrées', () => { expect(tvStore.getUw).toHaveBeenNthCalledWith(2, '6', '4', 2.5); expect(uw).toBe(1 / (1 / 18.8 + 1 / 18.8 + 0.07)); }); + + test('Doit retomber sur un uw de 1 pour la double-fenêtre si celui-ci est introuvable', () => { + // uw de la baie principale saisi, uw de la double-fenêtre indéterminé (getUw indéfini) + // => le terme 1/uw_double est remplacé par 1/1 dans la mise en série. + vi.spyOn(tvStore, 'getUw').mockReturnValue(undefined); + /** + * @type {BaieVitree} + */ + let bv = { + donnee_entree: { + uw_saisi: 3, + double_fenetre: 1 + }, + baie_vitree_double_fenetre: { + donnee_entree: { + enum_type_materiaux_menuiserie_id: '4', + enum_type_baie_id: '6' + } + } + }; + + let uw = service.uw(bv, 2.4); + expect(uw).toBe(1 / (1 / 3 + 1 / 1 + 0.07)); + }); }); describe('Determination du coefficient ug', () => { @@ -258,6 +309,52 @@ describe('Calcul de déperdition des baies vitrées', () => { expect(uw).toBe(18.8); }); + test("Ne recherche pas l'épaisseur la plus proche si l'épaisseur de lame n'est pas renseignée", () => { + // Double vitrage sans epaisseur_lame : e vaut NaN (falsy), la recherche de + // l'épaisseur tabulée la plus proche est court-circuitée. + vi.spyOn(tvStore, 'getUg').mockReturnValue(18.8); + const availableSpy = vi.spyOn(tvStore, 'getEpaisseurAvailableForUg').mockReturnValue([6, 8]); + + /** + * @type {BaieVitreeDE} + */ + let bvDE = { + enum_type_vitrage_id: '2', + vitrage_vir: '1', + enum_inclinaison_vitrage_id: '2', + enum_type_gaz_lame_id: '1' + }; + + let ug = service.ug(bvDE); + + expect(availableSpy).toHaveBeenCalledOnce(); + expect(tvStore.getUg).toHaveBeenCalledWith('2', '1', '2', '1', NaN); + expect(ug).toBe(18.8); + }); + + test("Ignore les épaisseurs tabulées supérieures à l'épaisseur réelle en cherchant la plus proche", () => { + // Épaisseur réelle 18 absente de la table : on retient la plus grande épaisseur + // tabulée strictement inférieure (8), en ignorant les épaisseurs supérieures (25). + vi.spyOn(tvStore, 'getUg').mockReturnValue(18.8); + vi.spyOn(tvStore, 'getEpaisseurAvailableForUg').mockReturnValue([6, 8, 25]); + + /** + * @type {BaieVitreeDE} + */ + let bvDE = { + enum_type_vitrage_id: '2', + vitrage_vir: '1', + enum_inclinaison_vitrage_id: '2', + enum_type_gaz_lame_id: '1', + epaisseur_lame: '18' + }; + + let ug = service.ug(bvDE); + + expect(tvStore.getUg).toHaveBeenCalledWith('2', '1', '2', '1', 8); + expect(ug).toBe(18.8); + }); + test('Doit appeler tvStore avec un équivalent double vitrage pour un sur vitrage avec majoration de 0.1', () => { vi.spyOn(tvStore, 'getUg').mockReturnValue(18.8); @@ -445,7 +542,33 @@ 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", () => { + test('Transmet la valeur de la zone climatique au calcul de b quand le contexte la renseigne', () => { + // Contexte avec zoneClimatique défini : ctx.zoneClimatique?.value est bien résolu et + // transmis à b (utile notamment pour les espaces tampons solarisés, adjacence 10). + const bSpy = vi.spyOn(service, 'b').mockReturnValue(0.85); + + const bv = { + donnee_entree: { + enum_type_adjacence_id: '10', + surface_aiu: 8, + surface_aue: 20, + enum_cfg_isolation_lnc_id: '9', + ug_saisi: 2.5, + uw_saisi: 2.8, + enum_type_fermeture_id: '1' + } + }; + + const di = service.execute({ zoneClimatique: { value: 'h1a' } }, bv); + + expect(bSpy).toHaveBeenCalledWith( + expect.objectContaining({ enumTypeAdjacenceId: '10', zoneClimatique: 'h1a' }) + ); + expect(di.b).toBe(0.85); + expect(di.u_menuiserie).toBe(2.8); + }); + + 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 d08663ab..763ebfd9 100755 --- a/src/features/engine/domain/enveloppe/deperdition-enveloppe.service.spec.js +++ b/src/features/engine/domain/enveloppe/deperdition-enveloppe.service.spec.js @@ -8,8 +8,9 @@ import { DeperditionPorteService } from './porte/deperdition-porte.service.js'; import { DeperditionPlancherHautService } from './plancher_haut/deperdition-plancher-haut.service.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 { beforeEach, describe, expect, test, vi } from 'vitest'; import b from '../../../../3.1_b.js'; +import { describeIntegration } from '../../../../../test/helpers/integration-test.js'; /** @type {DeperditionPorteService} **/ let deperditionPorteService; @@ -215,6 +216,273 @@ describe('Calcul des déperditions', () => { ); }); + describe('deperditions (agrégation GV + ventilation) avec des doubles de test', () => { + /** + * Construit un service avec huit doubles de test dont la méthode `execute` + * renvoie le `__di` (donnée intermédiaire) attaché à chaque donnée d'entrée. + */ + const buildService = () => { + const murService = { execute: vi.fn((ctx, de) => de.__di) }; + const porteService = { execute: vi.fn((ctx, de) => de.__di) }; + const plancherBasService = { execute: vi.fn((ctx, de) => de.__di) }; + const plancherHautService = { execute: vi.fn((ctx, de) => de.__di) }; + const baieVitreeService = { execute: vi.fn((ctx, bv) => bv.donnee_entree.__di) }; + const espaceTamponService = { execute: vi.fn(() => ({ tag: 'ets-di' })) }; + const pontThermiqueService = { execute: vi.fn((ctx, env, de) => de.__di) }; + const ventilationService = { execute: vi.fn((ctx, de) => de.__di) }; + + const service = new DeperditionEnveloppeService( + murService, + porteService, + plancherBasService, + plancherHautService, + baieVitreeService, + espaceTamponService, + pontThermiqueService, + ventilationService + ); + + return { + service, + murService, + porteService, + plancherBasService, + plancherHautService, + baieVitreeService, + espaceTamponService, + pontThermiqueService, + ventilationService + }; + }; + + test('somme les contributions de chaque paroi et alimente la ventilation avec les surfaces', () => { + const { service, espaceTamponService, ventilationService } = buildService(); + + const enveloppe = { + mur_collection: { + mur: [ + { + // déperditif, isolé "connu" (1) => surfaceNonIsolee + donnee_entree: { + surface_paroi_opaque: 10, + enum_type_adjacence_id: '1', + enum_type_isolation_id: '1', + __di: { b: 0.5, umur: 2 } + } + }, + { + // b > 0 mais adjacence 22 (local non déperditif) => non compté en surface déperditive, + // isolation hors ['1','2'] => surfaceIsolee + donnee_entree: { + surface_paroi_opaque: 20, + enum_type_adjacence_id: '22', + enum_type_isolation_id: '3', + __di: { b: 0.8, umur: 1 } + } + }, + { + // b = 0 => aucune surface comptée + donnee_entree: { + surface_paroi_opaque: 5, + enum_type_adjacence_id: '1', + enum_type_isolation_id: '2', + __di: { b: 0, umur: 3 } + } + } + ] + }, + porte_collection: { + porte: [ + { + donnee_entree: { + surface_porte: 2, + presence_joint: true, + __di: { b: 0.5, uporte: 3 } + } + }, + { + donnee_entree: { + surface_porte: 4, + presence_joint: false, + __di: { b: 0, uporte: 2 } + } + } + ] + }, + plancher_bas_collection: { + plancher_bas: [ + { + donnee_entree: { + surface_paroi_opaque: 8, + __di: { b: 0.9, upb_final: 1.5 } + } + } + ] + }, + plancher_haut_collection: { + plancher_haut: [ + { + donnee_entree: { + surface_paroi_opaque: 12, + enum_type_adjacence_id: '1', + enum_type_isolation_id: '1', + __di: { b: 0.7, uph: 1 } + } + }, + { + donnee_entree: { + surface_paroi_opaque: 6, + enum_type_adjacence_id: '22', + enum_type_isolation_id: '5', + __di: { b: 0.5, uph: 2 } + } + }, + { + donnee_entree: { + surface_paroi_opaque: 3, + enum_type_adjacence_id: '1', + enum_type_isolation_id: '1', + __di: { b: 0, uph: 1 } + } + } + ] + }, + baie_vitree_collection: { + baie_vitree: [ + { + donnee_entree: { + surface_totale_baie: 5, + presence_joint: true, + __di: { b: 0.6, u_menuiserie: 2 } + } + }, + { + donnee_entree: { + surface_totale_baie: 3, + presence_joint: false, + __di: { b: 0, u_menuiserie: 1 } + } + } + ] + }, + ets_collection: { + ets: { donnee_entree: {} } + }, + pont_thermique_collection: { + pont_thermique: [ + { + // pourcentage explicite + donnee_entree: { l: 4, pourcentage_valeur_pont_thermique: 0.5, __di: { k: 0.2 } } + }, + { + // pourcentage absent => 1 par défaut + donnee_entree: { l: 2, __di: { k: 0.3 } } + } + ] + } + }; + + const logement = { + enveloppe, + ventilation_collection: { + ventilation: [{ donnee_entree: { __di: { hvent: 100, hperm: 50 } } }] + } + }; + + const ctx = { zoneClimatique: { value: 'h1a' } }; + const resultat = service.deperditions(ctx, logement); + + // Contributions GV (b * surface * u) + expect(resultat.deperdition_mur).toBeCloseTo(0.5 * 10 * 2 + 0.8 * 20 * 1 + 0, 9); // 26 + expect(resultat.deperdition_porte).toBeCloseTo(0.5 * 2 * 3, 9); // 3 + expect(resultat.deperdition_plancher_bas).toBeCloseTo(0.9 * 8 * 1.5, 9); // 10.8 + expect(resultat.deperdition_plancher_haut).toBeCloseTo(0.7 * 12 * 1 + 0.5 * 6 * 2, 9); // 14.4 + expect(resultat.deperdition_baie_vitree).toBeCloseTo(0.6 * 5 * 2, 9); // 6 + expect(resultat.deperdition_pont_thermique).toBeCloseTo(4 * 0.2 * 0.5 + 2 * 0.3 * 1, 9); // 1 + expect(resultat.hvent).toBe(100); + expect(resultat.hperm).toBe(50); + + // deperdition_enveloppe = somme GV + hvent + hperm + expect(resultat.deperdition_enveloppe).toBeCloseTo( + 26 + 3 + 10.8 + 14.4 + 6 + 1 + 100 + 50, + 9 + ); + + // L'espace tampon a bien été calculé et stocké + expect(espaceTamponService.execute).toHaveBeenCalledOnce(); + expect(enveloppe.ets_collection.ets.donnee_intermediaire).toStrictEqual({ tag: 'ets-di' }); + + // La ventilation reçoit les surfaces agrégées : + // déperditive = 10 (mur A) + 2 (porte A) + 12 (ph A) + 5 (baie A) = 29 + // isolée = 20 (mur B) + 6 (ph B) = 26 + // non isolée = 10 (mur A) + 12 (ph A) = 22 + // menuiserie avec joint = 2 (porte A) + 5 (baie A) = 7 + // menuiserie sans joint = 4 (porte B) + 3 (baie B) = 7 + expect(ventilationService.execute).toHaveBeenCalledWith( + ctx, + { __di: { hvent: 100, hperm: 50 } }, + 29, + 26, + 22, + 7, + 7 + ); + }); + + test('utilise la première véranda lorsque les ETS sont dupliqués (tableau)', () => { + const { service, espaceTamponService } = buildService(); + + const premierEts = { donnee_entree: { reference: 'ets-1' } }; + const enveloppe = { + mur_collection: {}, + porte_collection: {}, + plancher_bas_collection: {}, + plancher_haut_collection: {}, + baie_vitree_collection: {}, + ets_collection: { ets: [premierEts, { donnee_entree: { reference: 'ets-2' } }] }, + pont_thermique_collection: {} + }; + const logement = { enveloppe, ventilation_collection: {} }; + + service.deperditions({ zoneClimatique: { value: 'h1a' } }, logement); + + // Seule la première véranda est traitée + expect(espaceTamponService.execute).toHaveBeenCalledOnce(); + expect(premierEts.donnee_intermediaire).toStrictEqual({ tag: 'ets-di' }); + }); + + test('renvoie des déperditions nulles pour une enveloppe et une ventilation vides', () => { + const { service, espaceTamponService, ventilationService } = buildService(); + + const enveloppe = { + mur_collection: {}, + porte_collection: {}, + plancher_bas_collection: {}, + plancher_haut_collection: {}, + baie_vitree_collection: {}, + pont_thermique_collection: {} + }; + const logement = { enveloppe, ventilation_collection: {} }; + + const resultat = service.deperditions({ zoneClimatique: { value: 'h1a' } }, logement); + + // Aucune paroi, aucun ETS, aucune ventilation + expect(espaceTamponService.execute).not.toHaveBeenCalled(); + expect(ventilationService.execute).not.toHaveBeenCalled(); + expect(resultat).toStrictEqual({ + deperdition_mur: 0, + deperdition_plancher_bas: 0, + deperdition_plancher_haut: 0, + deperdition_baie_vitree: 0, + deperdition_pont_thermique: 0, + deperdition_porte: 0, + hperm: 0, + hvent: 0, + deperdition_enveloppe: 0 + }); + }); + }); + describe.skip('Benchmark', () => { test('reworked', () => { const data = { @@ -249,7 +517,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/deperdition.service.spec.js b/src/features/engine/domain/enveloppe/deperdition.service.spec.js new file mode 100644 index 00000000..62b23200 --- /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/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 2de6049a..93ae4a9e 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 a7e5edd3..e282c6ad 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; @@ -333,6 +334,121 @@ describe('Calcul de déperdition des murs', () => { expect(di.umur0).toBeCloseTo(0.81545); expect(di.umur).toBeCloseTo(0.81545); }); + + test('Isolation inconnue (méthode 2) : umur est plafonné par la valeur forfaitaire de la table', () => { + // enum_methode_saisie_u_id = '2' => on prend le min entre umur_nu et la valeur tabulée. + /** @type {Contexte} */ + const ctx = { + effetJoule: false, + enumPeriodeConstructionId: '2', + zoneClimatique: { id: '3' } + }; + /** @type {MurDE} */ + const de = { + enum_materiaux_structure_mur_id: '12', + enum_methode_saisie_u_id: '2', + enum_methode_saisie_u0_id: '2', + epaisseur_structure: 25, + enum_type_doublage_id: '2', + enum_type_isolation_id: '2' + }; + + const di = service.execute(ctx, de); + // Valeurs de référence de régression + expect(di.umur0).toBeCloseTo(2.3); + expect(di.umur).toBeCloseTo(2.3); + }); + + test("Épaisseur d'isolation saisie mais manquante (méthode 4) : repli sur min(umur0, 2.5)", () => { + // Sans epaisseur_isolation, un avertissement est émis et umur = min(umur0, 2.5). + /** @type {Contexte} */ + const ctx = { + effetJoule: false, + enumPeriodeConstructionId: '2', + zoneClimatique: { id: '3' } + }; + /** @type {MurDE} */ + const de = { + enum_materiaux_structure_mur_id: '12', + enum_methode_saisie_u_id: '4', + enum_methode_saisie_u0_id: '2', + epaisseur_structure: 25, + enum_type_doublage_id: '2', + enum_type_isolation_id: '2', + description: 'mur sans épaisseur isolation' + }; + + const di = service.execute(ctx, de); + expect(di.umur0).toBeCloseTo(2.3); + expect(di.umur).toBeCloseTo(2.3); + }); + + test("Résistance d'isolation saisie (méthode 5) : umur calculé à partir de la résistance", () => { + /** @type {Contexte} */ + const ctx = { + effetJoule: false, + enumPeriodeConstructionId: '2', + zoneClimatique: { id: '3' } + }; + /** @type {MurDE} */ + const de = { + enum_materiaux_structure_mur_id: '12', + enum_methode_saisie_u_id: '5', + enum_methode_saisie_u0_id: '2', + epaisseur_structure: 25, + enum_type_doublage_id: '2', + enum_type_isolation_id: '3', + resistance_isolation: 2.5 + }; + + const di = service.execute(ctx, de); + expect(di.umur0).toBeCloseTo(2.3); + // 1 / (1 / 2.3 + 2.5) => valeur de référence de régression + expect(di.umur).toBeCloseTo(0.34074); + }); + + test('umur0 via matériau seul (méthode u0 = 1) sans épaisseur de structure', () => { + /** @type {Contexte} */ + const ctx = { + effetJoule: false, + enumPeriodeConstructionId: '2', + zoneClimatique: { id: '3' } + }; + /** @type {MurDE} */ + const de = { + enum_materiaux_structure_mur_id: '12', + enum_methode_saisie_u_id: '1', + enum_methode_saisie_u0_id: '1', + enum_type_doublage_id: '2', + enum_type_isolation_id: '2' + }; + + const di = service.execute(ctx, de); + // Matériau seul => valeur forfaitaire plafonnée à 2.5 + expect(di.umur0).toBeCloseTo(2.5); + expect(di.umur).toBeCloseTo(2.5); + }); + + test('umur0 saisi directement via une méthode u0 non gérée (défaut)', () => { + /** @type {Contexte} */ + const ctx = { + effetJoule: false, + enumPeriodeConstructionId: '2', + zoneClimatique: { id: '3' } + }; + /** @type {MurDE} */ + const de = { + enum_methode_saisie_u_id: '1', + enum_methode_saisie_u0_id: '9', + umur0_saisi: 1.234, + enum_type_doublage_id: '2', + enum_type_isolation_id: '2' + }; + + const di = service.execute(ctx, de); + expect(di.umur0).toBeCloseTo(1.234); + expect(di.umur).toBeCloseTo(1.234); + }); }); test.each([ @@ -396,7 +512,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 43e9d6b4..289df1d8 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; @@ -180,6 +181,138 @@ describe('Calcul de déperdition des planchers bas', () => { expect(di.upb0).toBeUndefined(); expect(di.upb_final).toBeCloseTo(1.25); }); + + test('Isolation inconnue (méthode 2) : upb plafonné par la valeur forfaitaire de la table', () => { + /** @type {Contexte} */ + const ctx = { + effetJoule: false, + enumPeriodeConstructionId: '6', + zoneClimatique: { id: '3' } + }; + /** @type {PlancherBasDE} */ + const de = { + enum_type_adjacence_id: '21', + enum_type_plancher_bas_id: '2', + enum_methode_saisie_u0_id: '2', + enum_type_isolation_id: '3', + enum_methode_saisie_u_id: '2', + enum_periode_isolation_id: '6', + calcul_ue: 0 + }; + + const di = service.execute(ctx, de, []); + expect(di.upb0).toBeCloseTo(1.45); + // Valeur de référence de régression + expect(di.upb).toBeCloseTo(0.5); + expect(di.upb_final).toBeCloseTo(0.5); + }); + + test.each([ + // Méthodes 7 (année d'isolation) et 8 (année de construction) : même table forfaitaire. + { enumMethodeSaisieUId: '7' }, + { enumMethodeSaisieUId: '8' } + ])( + 'upb via table forfaitaire pour la méthode de saisie $enumMethodeSaisieUId', + ({ enumMethodeSaisieUId }) => { + /** @type {Contexte} */ + const ctx = { + effetJoule: false, + enumPeriodeConstructionId: '6', + zoneClimatique: { id: '3' } + }; + /** @type {PlancherBasDE} */ + const de = { + enum_type_adjacence_id: '21', + enum_type_plancher_bas_id: '2', + enum_methode_saisie_u0_id: '2', + enum_type_isolation_id: '3', + enum_methode_saisie_u_id: enumMethodeSaisieUId, + enum_periode_isolation_id: '6', + calcul_ue: 0 + }; + + const di = service.execute(ctx, de, []); + expect(di.upb0).toBeCloseTo(1.45); + // Valeur de référence de régression + expect(di.upb).toBeCloseTo(0.5); + } + ); + + test("Résistance d'isolation saisie (méthode 6) : upb calculé à partir de la résistance", () => { + /** @type {Contexte} */ + const ctx = { + effetJoule: false, + enumPeriodeConstructionId: '6', + zoneClimatique: { id: '3' } + }; + /** @type {PlancherBasDE} */ + const de = { + enum_type_adjacence_id: '21', + enum_type_plancher_bas_id: '2', + enum_methode_saisie_u0_id: '2', + enum_type_isolation_id: '3', + enum_methode_saisie_u_id: '6', + resistance_isolation: 2.5, + calcul_ue: 0 + }; + + const di = service.execute(ctx, de, []); + expect(di.upb0).toBeCloseTo(1.45); + // 1 / (1 / 1.45 + 2.5) => valeur de référence de régression + expect(di.upb).toBeCloseTo(0.31351); + }); + + test("Résistance d'isolation saisie (méthode 5) : upb calculé à partir de la résistance", () => { + /** @type {Contexte} */ + const ctx = { + effetJoule: false, + enumPeriodeConstructionId: '6', + zoneClimatique: { id: '3' } + }; + /** @type {PlancherBasDE} */ + const de = { + enum_type_adjacence_id: '21', + enum_type_plancher_bas_id: '2', + enum_methode_saisie_u0_id: '2', + enum_type_isolation_id: '3', + enum_methode_saisie_u_id: '5', + resistance_isolation: 2.5, + calcul_ue: 0 + }; + + const di = service.execute(ctx, de, []); + expect(di.upb0).toBeCloseTo(1.45); + // 1 / (1 / 1.45 + 2.5) => valeur de référence de régression + expect(di.upb).toBeCloseTo(0.31351); + }); + + test('Calcul Ue avec repli sur surface de paroi et périmètre nul (dsp par défaut = 1)', () => { + // Adjacence terre-plein, calcul_ue = 0 : le upb_final passe par la table Ue. + // Le plancher n'a ni surface_ue ni perimetre_ue => on retombe sur surface_paroi_opaque + // et un dsp forfaitaire de 1. + /** @type {Contexte} */ + const ctx = { + effetJoule: false, + enumPeriodeConstructionId: '6', + zoneClimatique: { id: '3' } + }; + /** @type {PlancherBasDE} */ + const de = { + enum_type_adjacence_id: '5', + enum_type_plancher_bas_id: '9', + enum_methode_saisie_u0_id: '2', + enum_type_isolation_id: '2', + enum_methode_saisie_u_id: '1', + calcul_ue: 0, + surface_paroi_opaque: 40 + }; + + const di = service.execute(ctx, de, [{ donnee_entree: de }]); + expect(di.upb0).toBeCloseTo(2); + expect(di.upb).toBeCloseTo(2); + // Valeur de référence de régression (Ue table, dsp = 1) + expect(di.upb_final).toBeCloseTo(0.74421); + }); }); test.each([ @@ -267,7 +400,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 edbbbc6f..47c81b75 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; @@ -193,6 +194,130 @@ describe('Calcul de déperdition des planchers haut', () => { expect(di.uph0).toBeCloseTo(2.5); expect(di.uph).toBeCloseTo(0.4); }); + + test('Plancher non isolé (méthode 1) : uph vaut uph_nu', () => { + /** @type {Contexte} */ + const ctx = { + effetJoule: false, + enumPeriodeConstructionId: '2', + zoneClimatique: { id: '3' } + }; + /** @type {PlancherHautDE} */ + const de = { + enum_type_adjacence_id: '1', + enum_type_plancher_haut_id: '10', + enum_methode_saisie_u0_id: '2', + enum_type_isolation_id: '2', + enum_methode_saisie_u_id: '1' + }; + + const di = service.execute(ctx, de); + expect(di.uph0).toBeCloseTo(2.3); + expect(di.uph).toBeCloseTo(2.3); + }); + + test("Épaisseur d'isolation saisie (méthode 4) : uph calculé à partir de l'épaisseur", () => { + /** @type {Contexte} */ + const ctx = { + effetJoule: false, + enumPeriodeConstructionId: '2', + zoneClimatique: { id: '3' } + }; + /** @type {PlancherHautDE} */ + const de = { + enum_type_adjacence_id: '1', + enum_type_plancher_haut_id: '10', + enum_methode_saisie_u0_id: '2', + enum_type_isolation_id: '3', + enum_methode_saisie_u_id: '4', + epaisseur_isolation: 10 + }; + + const di = service.execute(ctx, de); + expect(di.uph0).toBeCloseTo(2.3); + // 1 / (1 / 2.3 + (10 * 0.01) / 0.04) => valeur de référence de régression + expect(di.uph).toBeCloseTo(0.34074); + }); + + test("Résistance d'isolation saisie (méthode 6) : uph calculé à partir de la résistance", () => { + /** @type {Contexte} */ + const ctx = { + effetJoule: false, + enumPeriodeConstructionId: '2', + zoneClimatique: { id: '3' } + }; + /** @type {PlancherHautDE} */ + const de = { + enum_type_adjacence_id: '1', + enum_type_plancher_haut_id: '10', + enum_methode_saisie_u0_id: '2', + enum_type_isolation_id: '3', + enum_methode_saisie_u_id: '6', + resistance_isolation: 2.5 + }; + + const di = service.execute(ctx, de); + expect(di.uph0).toBeCloseTo(2.3); + // 1 / (1 / 2.3 + 2.5) => valeur de référence de régression + expect(di.uph).toBeCloseTo(0.34074); + }); + + test.each([ + // Isolation inconnue via table forfaitaire (méthode 2) et variantes épaisseur/résistance. + { enumMethodeSaisieUId: '2', uphExpected: 0.75 }, + { enumMethodeSaisieUId: '3', epaisseurIsolation: 10, uphExpected: 0.34074 }, + { enumMethodeSaisieUId: '5', resistanceIsolation: 2.5, uphExpected: 0.34074 } + ])( + 'uph pour la méthode de saisie $enumMethodeSaisieUId', + ({ enumMethodeSaisieUId, epaisseurIsolation, resistanceIsolation, uphExpected }) => { + /** @type {Contexte} */ + const ctx = { + effetJoule: false, + enumPeriodeConstructionId: '2', + zoneClimatique: { id: '3' } + }; + /** @type {PlancherHautDE} */ + const de = { + enum_type_adjacence_id: '1', + enum_type_plancher_haut_id: '10', + enum_methode_saisie_u0_id: '2', + enum_type_isolation_id: '3', + enum_periode_isolation_id: '3', + enum_methode_saisie_u_id: enumMethodeSaisieUId, + epaisseur_isolation: epaisseurIsolation, + resistance_isolation: resistanceIsolation + }; + + const di = service.execute(ctx, de); + expect(di.uph0).toBeCloseTo(2.3); + // Valeurs de référence de régression + expect(di.uph).toBeCloseTo(uphExpected); + } + ); + + test("Adjacence 'locaux non chauffés non accessible' (7) : uph_tab pris en catégorie terrasse", () => { + // @see Methode_de_calcul_3CL_DPE_2021-338.pdf - §3.2.3.1 + /** @type {Contexte} */ + const ctx = { + effetJoule: false, + enumPeriodeConstructionId: '2', + zoneClimatique: { id: '3' } + }; + /** @type {PlancherHautDE} */ + const de = { + enum_type_adjacence_id: '7', + enum_type_plancher_haut_id: '1', + enum_methode_saisie_u0_id: '2', + enum_type_isolation_id: '3', + enum_periode_isolation_id: '6', + enum_methode_saisie_u_id: '8' + }; + + const di = service.execute(ctx, de); + expect(di.uph0).toBeCloseTo(2.5); + // Valeur de référence de régression (catégorie terrasse) + expect(di.uph).toBeCloseTo(0.4); + }); }); test.each([ @@ -256,7 +381,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 174f64e0..2882df33 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; @@ -421,6 +422,22 @@ describe('Calcul de déperdition des ponts thermiques', () => { expect(tvStore.getKForMurById).not.toHaveBeenCalled(); expect(tvStore.getKForMenuiserie).toHaveBeenCalledWith(5, 'iti', 3, 0, 5); }); + + test('Pont thermique nul pour une paroi en brique de verre (vitrage 5)', () => { + // @see Methode_de_calcul_3CL_DPE_2021-338.pdf - §3.4.5 Menuiserie / mur + // Les liaisons avec des parois en brique de verre (type_vitrage 5) sont prises nulles. + vi.spyOn(tvStore, 'getKForMenuiserie').mockReturnValue(0.25); + + const pontThermiqueDE = { reference_1: 'reference' }; + const enveloppe = { + baie_vitree_collection: { + baie_vitree: [{ donnee_entree: { reference: 'reference', enum_type_vitrage_id: '5' } }] + } + }; + + expect(service.pontThermiqueMenuiserieMur(pontThermiqueDE, enveloppe, 3)).toBe(0); + expect(tvStore.getKForMenuiserie).not.toHaveBeenCalled(); + }); }); describe("Calcul de l'isolation du mur", () => { @@ -616,7 +633,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 d3affdf4..cbae5f67 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 ad5d1e6e..b18f7e60 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,8 @@ 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'; +import { logger } from '../../../../../core/util/logger/log-service.js'; /** @type {DeperditionVentilationService} **/ let service; @@ -87,6 +89,17 @@ describe('Calcul de déperdition des portes', () => { expectedIsolationSurface: '1', expectedPresenceJointsMenuiserie: undefined }, + { + // 1948-1974 mais moins de 50% de surface isolée => isolationSurface '0' + q4paConvSaisi: undefined, + enumPeriodeConstructionId: '2', + surfaceIsolee: 100, + surfaceNonIsolee: 200, + surfaceMenuiserieAvecJoint: 0, + surfaceMenuiserieSansJoint: 0, + expectedIsolationSurface: '0', + expectedPresenceJointsMenuiserie: undefined + }, { q4paConvSaisi: undefined, enumPeriodeConstructionId: '3', @@ -408,7 +421,60 @@ describe('Calcul de déperdition des portes', () => { ); }); - describe("Test d'intégration des ventilations", () => { + describe('execute (agrégation des déperditions et consommations de ventilation)', () => { + /** @type {Contexte} */ + const ctx = { + surfaceHabitable: 100, + hauteurSousPlafond: 2.5, + typeHabitation: TypeHabitation.MAISON, + enumPeriodeConstructionId: '3' + }; + + test('assemble hvent, hperm et la consommation des auxiliaires', () => { + vi.spyOn(tvStore, 'getDebitsVentilation').mockReturnValue({ + qvarep_conv: 10, + qvasouf_conv: 5, + smea_conv: 2 + }); + vi.spyOn(tvStore, 'getQ4paConv').mockReturnValue({ q4pa_conv: 1.7 }); + + /** @type {VentilationDE} */ + const de = { + enum_type_ventilation_id: '4', + ventilation_post_2012: true, + plusieurs_facade_exposee: false + }; + + const di = service.execute(ctx, de, 120, 80, 40, 10, 5); + + expect(tvStore.getDebitsVentilation).toHaveBeenCalledWith('4'); + // hvent = 0.34 * qvarep_conv * surfaceHabitable + expect(di.hvent).toBeCloseTo(340, 5); + // pventMoy (SF auto, maison, post 2012) = 35 => conso = 8.76 * 35 + expect(di.pvent_moy).toBe(35); + expect(di.conso_auxiliaire_ventilation).toBeCloseTo(306.6, 5); + // hperm : valeur de référence de régression + expect(di.hperm).toBeCloseTo(0.10692928032279153, 9); + }); + + test('conso des auxiliaires nulle sans type de ventilation ni indicateur post 2012', () => { + vi.spyOn(tvStore, 'getDebitsVentilation').mockReturnValue({}); + vi.spyOn(tvStore, 'getQ4paConv').mockReturnValue({ q4pa_conv: 1.7 }); + const errorSpy = vi.spyOn(logger, 'error').mockImplementation(() => {}); + + /** @type {VentilationDE} */ + const de = { plusieurs_facade_exposee: false }; + + const di = service.execute(ctx, de, 0, 0, 0, 0, 0); + + expect(errorSpy).toHaveBeenCalledOnce(); + // La consommation retournée vaut 0 (nombre), les clés conso ne sont donc pas ajoutées + expect(di.conso_auxiliaire_ventilation).toBeUndefined(); + expect(di.pvent_moy).toBeUndefined(); + }); + }); + + 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 257ed611..696b4e7f 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/features/normalizer/domain/dpe-normalizer.service.spec.js b/src/features/normalizer/domain/dpe-normalizer.service.spec.js index b3352bdf..bcb9a17a 100755 --- a/src/features/normalizer/domain/dpe-normalizer.service.spec.js +++ b/src/features/normalizer/domain/dpe-normalizer.service.spec.js @@ -1,3 +1,54 @@ -import { describe } from 'vitest'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; -describe.skip('Normalisation des DPE', () => {}); +// Isolation : on mocke le pré-processeur importé par le service afin de tester +// uniquement la logique de normalisation (clonage + délégation), sans dépendre +// du comportement réel de `DpePreProcessor` (testé dans son propre spec). +const preprocessMock = vi.fn(); +vi.mock('../../../preprocessor/index.js', () => ({ + DpePreProcessor: vi.fn().mockImplementation(() => ({ + preprocess: preprocessMock + })) +})); + +const { DpeNormalizerService } = await import('./dpe-normalizer.service.js'); +const { DpePreProcessor } = await import('../../../preprocessor/index.js'); + +describe('Normalisation des DPE (DpeNormalizerService)', () => { + beforeEach(() => { + preprocessMock.mockReset(); + vi.mocked(DpePreProcessor).mockClear(); + }); + + test("clone le DPE d'entrée sans le muter puis délègue au pré-processeur", () => { + const dpeOrigine = { logement: { enveloppe: { mur: [{ id: 1 }] } } }; + const service = new DpeNormalizerService(); + + const normalise = service.normalize(dpeOrigine); + + // Le pré-processeur est instancié et reçoit le clone à normaliser. + expect(DpePreProcessor).toHaveBeenCalledTimes(1); + expect(preprocessMock).toHaveBeenCalledTimes(1); + expect(preprocessMock).toHaveBeenCalledWith(normalise); + + // Le résultat est structurellement égal à l'entrée... + expect(normalise).toEqual(dpeOrigine); + // ...mais c'est bien un clone profond (aucune référence partagée). + expect(normalise).not.toBe(dpeOrigine); + expect(normalise.logement).not.toBe(dpeOrigine.logement); + expect(normalise.logement.enveloppe.mur).not.toBe(dpeOrigine.logement.enveloppe.mur); + }); + + test("ne modifie pas le DPE d'origine même si le pré-processeur mute le clone", () => { + // Le pré-processeur mocké mute le DPE reçu : l'original doit rester intact. + preprocessMock.mockImplementation((dpe) => { + dpe.ajoute = 'valeur'; + }); + const dpeOrigine = { a: 1 }; + const service = new DpeNormalizerService(); + + const normalise = service.normalize(dpeOrigine); + + expect(normalise).toEqual({ a: 1, ajoute: 'valeur' }); + expect(dpeOrigine).toEqual({ a: 1 }); + }); +}); diff --git a/src/ficheTechnique.spec.js b/src/ficheTechnique.spec.js index ea727573..2cb775b7 100644 --- a/src/ficheTechnique.spec.js +++ b/src/ficheTechnique.spec.js @@ -156,6 +156,32 @@ describe('FicheTechnique service tests', () => { expect(fiche.valeur).toBe('aucune'); }); + test('should normalize a single sous_fiche_technique into an array when filtering on classifications', async () => { + // sous_fiche_technique est un objet unique (et non un tableau) : il doit être normalisé + const dpeSingleSousFiche = { + fiche_technique_collection: { + fiche_technique: { + enum_categorie_fiche_technique_id: '10', + sous_fiche_technique_collection: { + sous_fiche_technique: { + description: 'Type de ventilation: VMC SF', + valeur: 'VMC SF' + } + } + } + } + }; + + // la classification 'VMC SF' correspond => la fiche est retournée + const fiche = getFicheTechnique(dpeSingleSousFiche, '10', 'ventilation', ['VMC SF']); + expect(fiche).not.toBeNull(); + expect(fiche.valeur).toBe('VMC SF'); + + // une classification absente => aucune fiche + const aucune = getFicheTechnique(dpeSingleSousFiche, '10', 'ventilation', ['absente']); + expect(aucune).toBeNull(); + }); + test('should not return a non existing fiche technique', async () => { let fiche = getFicheTechnique(dpe, '40', 'exposées'); expect(fiche).toBeNull(); diff --git a/src/index.spec.js b/src/index.spec.js new file mode 100644 index 00000000..c3fc4b8b --- /dev/null +++ b/src/index.spec.js @@ -0,0 +1,54 @@ +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', '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/preprocessor/index.js b/src/preprocessor/index.js new file mode 100644 index 00000000..a0380da8 --- /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/preprocessor/index.spec.js b/src/preprocessor/index.spec.js new file mode 100644 index 00000000..a7c9dcf7 --- /dev/null +++ b/src/preprocessor/index.spec.js @@ -0,0 +1,15 @@ +import { describe, expect, test } from 'vitest'; +import { DpePreProcessor } from './index.js'; + +describe('Pré-processeur du DPE (DpePreProcessor)', () => { + test('preprocess est un no-op : renvoie le DPE inchangé (même référence)', () => { + const preProcessor = new DpePreProcessor(); + const dpe = { logement: { enveloppe: {} } }; + + const resultat = preProcessor.preprocess(dpe); + + // Placeholder : le DPE est renvoyé tel quel, sans copie ni mutation. + expect(resultat).toBe(dpe); + expect(resultat).toEqual({ logement: { enveloppe: {} } }); + }); +}); diff --git a/src/tv.spec.js b/src/tv.spec.js new file mode 100644 index 00000000..cc694f62 --- /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(); + } + }); +}); diff --git a/src/utils.js b/src/utils.js index d7439ca0..a2f8d9a9 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; @@ -519,11 +558,14 @@ export function getRange(inputNumber, ranges) { } else { ranges.find((range, index) => { if (inputNumber < range) { + /* c8 ignore start -- branche défensive inatteignable : inputNumber < ranges[0] provoque + déjà le retour anticipé en amont, donc index vaut toujours > 0 ici. */ if (index > 0) { result.push(ranges[index - 1]); } else { result.push(ranges[index]); } + /* c8 ignore stop */ result.push(ranges[index]); return true; } diff --git a/src/utils.spec.js b/src/utils.spec.js index d66eaacf..a92e38fb 100644 --- a/src/utils.spec.js +++ b/src/utils.spec.js @@ -1,5 +1,60 @@ -import { convertExpression, getRange, getThicknessFromDescription } from './utils.js'; -import { describe, expect, test } from 'vitest'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +// --- Mocks des dépendances de données (contenu réel non testé ici) ----------------------------- +// enums.js et tv.js sont des tables de valeurs : on les mocke pour rendre les tests autonomes +// vis-à-vis de l'évolution des données réelles. lodash-es, fast-xml-parser et mathjs restent +// réels car leur comportement (set, parsing XML, evaluate) fait partie de ce que l'on vérifie. +vi.mock('./enums.js', () => ({ + default: { + materiaux: { 1: 'brique', 2: 'beton' } + } +})); + +vi.mock('./tv.js', () => ({ + default: { + // Table dédiée à tvColumnIDs : plusieurs colonnes enum_x_id, dont une indéfinie. + colonnes: [{ enum_x_id: '1|2' }, { enum_x_id: '2|3' }, { enum_x_id: undefined }], + // Table dédiée à tvMatch (version historique) et à tv(). + table_defaut: [ + { enum_x_id: '1', val: 'r0' }, + { enum_x_id: '2|3', val: 'r1' }, + { seuil: '≥50', val: 'r2' }, + { code: 'iti+ite', val: 'r3' }, + { code: 'abc', val: 'r4' } + ], + // Table dédiée à tvMatchOptimized (version optimisée). + table_opt: [{ a: 'brique', b: '1|2', c: '≥50', d: 'isolation par exterieur', e: 'zone 3' }] + } +})); + +const { + xmlParser, + set_bug_for_bug_compat, + useEnumAsString, + set_use_enum_as_string, + set_tv_match_optimized_version, + unset_tv_match_optimized_version, + add_references, + requestInputID, + requestInput, + getKeyByValue, + tvColumnIDs, + tvColumnLines, + tv, + removeKeyFromJSON, + clean_dpe, + getThicknessFromDescription, + collectionCanBeEmpty, + getVolumeStockageFromDescription, + cleanReference, + compareReferences, + isEffetJoule, + containsAnySubstring, + excel_to_js_exec, + convertExpression, + getRange +} = await import('./utils.js'); +const utils = await import('./utils.js'); describe('Utils unit tests', () => { test.each([ @@ -34,4 +89,465 @@ 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); + }); + }); + + // ------------------------------------------------------------------------------------------- + describe('xmlParser', () => { + // Règle : les balises listées dans collectionNames sont toujours forcées en tableau, même + // si la collection ne contient qu'un seul élément. + test('force les collections connues en tableau', () => { + const result = xmlParser.parse('1'); + expect(Array.isArray(result.root.mur)).toBe(true); + expect(result.root.mur).toStrictEqual([1]); + }); + + // Règle : une balise hors collectionNames n'est pas forcée en tableau. + test('ne force pas les balises hors collection en tableau', () => { + const result = xmlParser.parse('1'); + expect(Array.isArray(result.root.autre)).toBe(false); + }); + + // Règle du tagValueProcessor : les balises enum_* conservent leur valeur brute (chaîne), les + // valeurs numériques sont converties en nombre, les autres restent des chaînes. + test('préserve les enum_* en chaîne et convertit les nombres', () => { + const result = xmlParser.parse( + '0542hello' + ); + expect(result.root.enum_type_id).toBe('05'); + expect(result.root.count).toBe(42); + expect(result.root.name).toBe('hello'); + }); + }); + + // ------------------------------------------------------------------------------------------- + describe('drapeaux globaux', () => { + test('set_bug_for_bug_compat active le drapeau', () => { + set_bug_for_bug_compat(); + expect(utils.bug_for_bug_compat).toBe(true); + }); + + test('set_use_enum_as_string active le drapeau', () => { + set_use_enum_as_string(); + expect(utils.use_enum_as_string).toBe(true); + }); + + test('set/unset_tv_match_optimized_version bascule le drapeau', () => { + set_tv_match_optimized_version(); + expect(utils.tv_match_new_version).toBe(true); + unset_tv_match_optimized_version(); + expect(utils.tv_match_new_version).toBe(false); + }); + }); + + // ------------------------------------------------------------------------------------------- + describe('useEnumAsString', () => { + // Règle : seules les clés enum_* non nulles sont converties en chaîne, récursivement. + test('convertit récursivement les enum_* non nuls en chaîne', () => { + const obj = { + enum_a: 5, + enum_b: null, + other: 3, + nested: { enum_c: 7 } + }; + useEnumAsString(obj); + expect(obj.enum_a).toBe('5'); + expect(obj.enum_b).toBeNull(); + expect(obj.other).toBe(3); + expect(obj.nested.enum_c).toBe('7'); + }); + }); + + // ------------------------------------------------------------------------------------------- + describe('add_references', () => { + const buildCollections = (items) => ({ + mur_collection: { mur: items ? [...items] : undefined }, + plancher_haut_collection: { plancher_haut: items ? [...items] : undefined }, + plancher_bas_collection: { plancher_bas: items ? [...items] : undefined }, + baie_vitree_collection: { baie_vitree: items ? [...items] : undefined }, + porte_collection: { porte: items ? [...items] : undefined } + }); + + // Règle : chaque élément sans référence reçoit `_` ; ceux qui en ont une la + // conservent. + test('affecte une référence aux éléments qui n en ont pas', () => { + const enveloppe = { + mur_collection: { + mur: [{ donnee_entree: {} }, { donnee_entree: { reference: 'M' } }] + }, + plancher_haut_collection: { + plancher_haut: [{ donnee_entree: {} }, { donnee_entree: { reference: 'PH' } }] + }, + plancher_bas_collection: { + plancher_bas: [{ donnee_entree: {} }, { donnee_entree: { reference: 'PB' } }] + }, + baie_vitree_collection: { + baie_vitree: [{ donnee_entree: {} }, { donnee_entree: { reference: 'BV' } }] + }, + porte_collection: { + porte: [{ donnee_entree: {} }, { donnee_entree: { reference: 'P' } }] + } + }; + + add_references(enveloppe); + + expect(enveloppe.mur_collection.mur[0].donnee_entree.reference).toBe('mur_0'); + expect(enveloppe.mur_collection.mur[1].donnee_entree.reference).toBe('M'); + expect(enveloppe.plancher_haut_collection.plancher_haut[0].donnee_entree.reference).toBe( + 'plancher_haut_0' + ); + expect(enveloppe.plancher_bas_collection.plancher_bas[0].donnee_entree.reference).toBe( + 'plancher_bas_0' + ); + expect(enveloppe.baie_vitree_collection.baie_vitree[0].donnee_entree.reference).toBe( + 'baie_vitree_0' + ); + expect(enveloppe.porte_collection.porte[0].donnee_entree.reference).toBe('porte_0'); + }); + + // Règle : une collection absente (tableau indéfini) est traitée comme vide, sans erreur. + test('supporte des collections absentes', () => { + const enveloppe = buildCollections(null); + expect(() => add_references(enveloppe)).not.toThrow(); + }); + }); + + // ------------------------------------------------------------------------------------------- + describe('requestInput / requestInputID', () => { + test('requestInput : champ enum sans type retourne la valeur enum et publie les clés', () => { + const de = { enum_materiaux_id: '1' }; + const du = {}; + expect(requestInput(de, du, 'materiaux')).toBe('brique'); + expect(du.enum_materiaux_id).toStrictEqual(['1', '2']); + }); + + test('requestInput : champ enum avec type publie le type fourni', () => { + const de = { enum_materiaux_id: '2' }; + const du = {}; + expect(requestInput(de, du, 'materiaux', ['2'])).toBe('beton'); + expect(du.enum_materiaux_id).toStrictEqual(['2']); + }); + + test('requestInput : champ non enum avec type retourne la valeur brute', () => { + const de = { epaisseur: 5 }; + const du = {}; + expect(requestInput(de, du, 'epaisseur', 'number')).toBe(5); + expect(du.epaisseur).toBe('number'); + }); + + test('requestInput : champ non enum sans type journalise une erreur et retourne null', () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const du = {}; + expect(requestInput({}, du, 'epaisseur')).toBeNull(); + expect(spy).toHaveBeenCalledOnce(); + spy.mockRestore(); + }); + + test('requestInputID : sans type publie les clés et retourne l identifiant', () => { + const de = { enum_materiaux_id: '1' }; + const du = {}; + expect(requestInputID(de, du, 'materiaux')).toBe('1'); + expect(du.enum_materiaux_id).toStrictEqual(['1', '2']); + }); + + test('requestInputID : avec type publie le type fourni', () => { + const de = { enum_materiaux_id: '2' }; + const du = {}; + expect(requestInputID(de, du, 'materiaux', ['2'])).toBe('2'); + expect(du.enum_materiaux_id).toStrictEqual(['2']); + }); + }); + + // ------------------------------------------------------------------------------------------- + describe('getKeyByValue', () => { + test('retourne la clé associée à la valeur', () => { + expect(getKeyByValue({ a: 1, b: 2 }, 2)).toBe('b'); + }); + test('retourne undefined si la valeur est absente', () => { + expect(getKeyByValue({ a: 1 }, 99)).toBeUndefined(); + }); + }); + + // ------------------------------------------------------------------------------------------- + describe('tvColumnIDs', () => { + // Règle : agrège les identifiants uniques de la colonne enum__id, en éclatant les + // valeurs multiples séparées par « | » et en ignorant les cellules vides. + test('retourne les identifiants uniques éclatés', () => { + expect(tvColumnIDs('colonnes', 'x')).toStrictEqual(['1', '2', '3']); + }); + }); + + // ------------------------------------------------------------------------------------------- + describe('tv / tvMatch (version historique)', () => { + beforeEach(() => { + unset_tv_match_optimized_version(); + }); + + // Règle enum : la valeur est ancrée (^...$) et une cellule « a|b » matche si l'une des + // alternatives correspond. + test('matche une valeur enum contenue dans une cellule « a|b »', () => { + expect(tv('table_defaut', { enum_x_id: '3' })).toStrictEqual({ + enum_x_id: '2|3', + val: 'r1' + }); + }); + + // Règle seuil : pour un entier et une cellule « ≥/≤ », la comparaison est évaluée. + test('évalue un seuil « ≥ » pour une valeur entière', () => { + expect(tv('table_defaut', { seuil: 60 })).toStrictEqual({ seuil: '≥50', val: 'r2' }); + // 40 < 50 → pas de correspondance complète, retourne le meilleur match partiel (ici aucun + // autre champ commun → premier candidat comptabilisé nul). + }); + + // Règle d'échappement : « ^iti+ite$ » est transformé pour échapper le « + » littéral. + test('échappe le « + » dans un motif ancré', () => { + expect(tv('table_defaut', { code: '^iti+ite$' })).toStrictEqual({ + code: 'iti+ite', + val: 'r3' + }); + }); + + // Règle : sans correspondance complète, tv retourne la ligne au plus grand nombre de + // champs concordants. + test('retourne le meilleur match partiel faute de correspondance complète', () => { + expect(tv('table_defaut', { enum_x_id: '1', absent: 'z' })).toStrictEqual({ + enum_x_id: '1', + val: 'r0' + }); + }); + + // Règle : une clé absente de la ligne ne matche jamais. + test('une clé absente de la ligne empêche la correspondance', () => { + // « seuil » n'existe que sur r2 ; matcher enum_x_id + seuil ne matche complètement aucune + // ligne → meilleur partiel = r0 (enum_x_id concorde). + expect(tv('table_defaut', { enum_x_id: '1', seuil: 60 })).toStrictEqual({ + enum_x_id: '1', + val: 'r0' + }); + }); + }); + + // ------------------------------------------------------------------------------------------- + describe('tvColumnLines', () => { + beforeEach(() => { + unset_tv_match_optimized_version(); + }); + + // Règle : retourne les valeurs de la colonne demandée pour toutes les lignes qui matchent le + // matcher. + test('retourne les colonnes des lignes concordantes', () => { + expect(tvColumnLines('table_defaut', 'val', { enum_x_id: '1' })).toStrictEqual(['r0']); + }); + }); + + // ------------------------------------------------------------------------------------------- + describe('tv / tvMatchOptimized (version optimisée)', () => { + beforeEach(() => { + set_tv_match_optimized_version(); + }); + + // Correspondance exacte. + test('matche une valeur identique', () => { + expect(tv('table_opt', { a: 'brique' }).a).toBe('brique'); + }); + + // Correspondance après suppression des ancres ^...$. + test('matche après suppression des ancres du motif', () => { + expect(tv('table_opt', { a: '^brique$' }).a).toBe('brique'); + }); + + // Correspondance par sous-chaîne pour une valeur non numérique. + test('matche par sous-chaîne (valeur non numérique)', () => { + expect(tv('table_opt', { d: 'exterieur' }).d).toBe('isolation par exterieur'); + }); + + // Correspondance sur une cellule « a|b ». + test('matche une alternative d une cellule « a|b »', () => { + expect(tv('table_opt', { b: '2' }).b).toBe('1|2'); + }); + + // Comparaison de seuil « ≥ » pour un entier. + test('évalue un seuil « ≥ » pour une valeur entière', () => { + expect(tv('table_opt', { c: 60 }).c).toBe('≥50'); + }); + + // Correspondance par sous-chaîne finale (valeur numérique présente en fin de chaîne). + test('matche par sous-chaîne finale une valeur numérique', () => { + expect(tv('table_opt', { e: 3 }).e).toBe('zone 3'); + }); + + // Absence de correspondance : cellule vide. + test('ne matche pas une clé absente de la ligne', () => { + expect(tv('table_opt', { absent: 'x' })).toBeNull(); + }); + + // Absence de correspondance : sous-chaîne introuvable. + test('ne matche pas une sous-chaîne introuvable', () => { + expect(tv('table_opt', { a: 'zzz' })).toBeNull(); + }); + }); + + // ------------------------------------------------------------------------------------------- + describe('removeKeyFromJSON', () => { + // Règle : supprime récursivement la clé cible, sauf sous les branches listées dans skipKeys. + test('supprime récursivement la clé sauf sous les branches ignorées', () => { + const obj = { + a: 1, + remove_me: 2, + skip: { remove_me: 3 }, + nested: { remove_me: 4, b: 5 } + }; + removeKeyFromJSON(obj, 'remove_me', ['skip']); + expect(obj.remove_me).toBeUndefined(); + expect(obj.skip.remove_me).toBe(3); + expect(obj.nested.remove_me).toBeUndefined(); + expect(obj.nested.b).toBe(5); + }); + }); + + // ------------------------------------------------------------------------------------------- + describe('clean_dpe', () => { + // Règle : supprime les donnee_intermediaire sauf sous generateur_ecs/generateur_chauffage et + // remet logement.sortie à null. + test('nettoie les données intermédiaires et vide la sortie', () => { + const dpe = { + logement: { + sortie: { x: 1 }, + donnee_intermediaire: { y: 2 }, + generateur_ecs: [{ donnee_intermediaire: { keep: 1 } }] + } + }; + clean_dpe(dpe); + expect(dpe.logement.donnee_intermediaire).toBeUndefined(); + expect(dpe.logement.generateur_ecs[0].donnee_intermediaire).toStrictEqual({ keep: 1 }); + expect(dpe.logement.sortie).toBeNull(); + }); + }); + + // ------------------------------------------------------------------------------------------- + describe('collectionCanBeEmpty', () => { + const buildLogement = (ponts, deperditionMur) => ({ + enveloppe: { + pont_thermique_collection: ponts ? { pont_thermique: ponts } : {} + }, + sortie: { deperdition: { deperdition_mur: deperditionMur } } + }); + + // Règle : vide autorisé si aucun pont thermique de la liaison ET déperdition nulle. + test('vrai si aucun pont de liaison et déperdition nulle', () => { + const logement = buildLogement([{ donnee_entree: { enum_type_liaison_id: 5 } }], 0); + expect(collectionCanBeEmpty(logement, 'mur', 9)).toBe(true); + }); + + // Règle : faux si au moins un pont de la liaison existe. + test('faux si un pont de la liaison existe', () => { + const logement = buildLogement([{ donnee_entree: { enum_type_liaison_id: 5 } }], 0); + expect(collectionCanBeEmpty(logement, 'mur', 5)).toBe(false); + }); + + // Règle : faux si la déperdition est non nulle. + test('faux si la déperdition est non nulle', () => { + const logement = buildLogement([], 10); + expect(collectionCanBeEmpty(logement, 'mur', 5)).toBe(false); + }); + + // Règle : collection de ponts absente traitée comme vide. + test('supporte une collection de ponts absente', () => { + const logement = buildLogement(null, 0); + expect(collectionCanBeEmpty(logement, 'mur', 5)).toBe(true); + }); + }); + + // ------------------------------------------------------------------------------------------- + describe('getVolumeStockageFromDescription', () => { + test.each([ + [null, 0], + ['pas de contenance', 0], + ['ballon type contenance ballon 200 litres', 200] + ])('extrait le volume de « %s » → %s', (description, expected) => { + expect(getVolumeStockageFromDescription(description)).toBe(expected); + }); + }); + + // ------------------------------------------------------------------------------------------- + describe('cleanReference / compareReferences', () => { + test.each([ + ['Réf Élément 1', 'refelement1'], + [42, '42'], + [null, null], + [undefined, undefined] + ])('nettoie « %s » → %s', (input, expected) => { + expect(cleanReference(input)).toBe(expected); + }); + + test('compareReferences ignore espaces et accents', () => { + expect(compareReferences('Réf 1', 'ref1')).toBe(true); + expect(compareReferences('a', 'b')).toBe(false); + }); + }); + + // ------------------------------------------------------------------------------------------- + describe('isEffetJoule', () => { + const buildInstal = (genId, surface) => ({ + generateur_chauffage_collection: { + generateur_chauffage: [{ donnee_entree: { enum_type_generateur_ch_id: genId } }] + }, + donnee_entree: { surface_chauffee: surface } + }); + + // Règle : effet joule si la surface chauffée par résistance électrique est majoritaire (>= 50 %). + test('retourne « 1 » quand l effet joule est majoritaire', () => { + const instal = [buildInstal('100', 60), buildInstal('50', 40)]; + expect(isEffetJoule(instal)).toBe('1'); + }); + + test('retourne « 0 » quand l effet joule est minoritaire', () => { + const instal = [buildInstal('100', 30), buildInstal('50', 70)]; + expect(isEffetJoule(instal)).toBe('0'); + }); + }); + + // ------------------------------------------------------------------------------------------- + describe('containsAnySubstring', () => { + test.each([ + ['Hello World', ['xyz', 'wor'], true], + ['abc', ['xyz'], false], + [123, ['2'], true] + ])('« %s » contient une des sous-chaînes %s → %s', (mainString, substrings, expected) => { + expect(containsAnySubstring(mainString, substrings)).toBe(expected); + }); + }); }); diff --git a/test/helpers/integration-test.js b/test/helpers/integration-test.js new file mode 100644 index 00000000..27ecae00 --- /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; diff --git a/vitest.config.js b/vitest.config.js new file mode 100644 index 00000000..e8b648b7 --- /dev/null +++ b/vitest.config.js @@ -0,0 +1,30 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + coverage: { + provider: 'v8', + // On ne mesure que le vrai code métier de src/. + all: true, + include: ['src/**/*.js'], + exclude: [ + // Specs eux-mêmes. + '**/*.spec.js', + // Modèles / définitions de types TypeScript (pas de logique métier à tester). + '**/*.model.ts', + // Barrel du package (simple ré-export public). + 'src/index.js', + // Fichier mort/cassé (cf. CLAUDE.md, présent dans .eslintignore). + 'src/output.js' + ], + reporter: ['text', 'json', 'html'], + // Verrou : toute baisse de couverture fait échouer la CI. + thresholds: { + statements: 100, + branches: 100, + functions: 100, + lines: 100 + } + } + } +});