Refacto du code coté Python, java et coté angular afin de mieux séparer les responsabilité et d'avoir moins de répétitivité dans le code.
Some checks failed
E2E Tests / e2e (push) Waiting to run
Tests unitaires / Web (Angular · vitest + couverture) (push) Successful in 26s
Build & Push Images / tests (push) Failing after 13s
Build & Push Images / build (brain) (push) Has been skipped
Build & Push Images / build (core) (push) Has been skipped
Build & Push Images / build (web) (push) Has been skipped
Build & Push Images / build-switcher (push) Has been skipped
Tests unitaires / Brain (Python · pytest + couverture) (push) Successful in 1m12s
Tests unitaires / Core (Java · mvn test + JaCoCo) (push) Failing after 1m28s
Some checks failed
E2E Tests / e2e (push) Waiting to run
Tests unitaires / Web (Angular · vitest + couverture) (push) Successful in 26s
Build & Push Images / tests (push) Failing after 13s
Build & Push Images / build (brain) (push) Has been skipped
Build & Push Images / build (core) (push) Has been skipped
Build & Push Images / build (web) (push) Has been skipped
Build & Push Images / build-switcher (push) Has been skipped
Tests unitaires / Brain (Python · pytest + couverture) (push) Successful in 1m12s
Tests unitaires / Core (Java · mvn test + JaCoCo) (push) Failing after 1m28s
Mise en place de tests unitaires coté Python et Angular Mise en place de la couverture de test directement dans le workflow : le programme ne build pas si jamais un test échoue Passage en v0.16.2 en conséquence
This commit is contained in:
505
web/package-lock.json
generated
505
web/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "loremind-web",
|
||||
"version": "0.16.1",
|
||||
"version": "0.16.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "loremind-web",
|
||||
"version": "0.16.1",
|
||||
"version": "0.16.2",
|
||||
"dependencies": {
|
||||
"@angular/animations": "^21.2.16",
|
||||
"@angular/common": "^21.2.16",
|
||||
@@ -32,7 +32,9 @@
|
||||
"@emnapi/core": "^1.11.0",
|
||||
"@emnapi/runtime": "^1.11.0",
|
||||
"@playwright/test": "^1.59.1",
|
||||
"typescript": "~5.9.3"
|
||||
"@vitest/coverage-v8": "^4.1.9",
|
||||
"typescript": "~5.9.3",
|
||||
"vitest": "^4.1.9"
|
||||
}
|
||||
},
|
||||
"node_modules/@algolia/abtesting": {
|
||||
@@ -2518,6 +2520,16 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@bcoe/v8-coverage": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
|
||||
"integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@discoveryjs/json-ext": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.6.3.tgz",
|
||||
@@ -5988,6 +6000,17 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/chai": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/deep-eql": "*",
|
||||
"assertion-error": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/connect": {
|
||||
"version": "3.4.38",
|
||||
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
|
||||
@@ -6009,6 +6032,13 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/deep-eql": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/eslint": {
|
||||
"version": "9.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
|
||||
@@ -6101,7 +6131,6 @@
|
||||
"integrity": "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": ">=7.24.0 <7.24.7"
|
||||
}
|
||||
@@ -6210,6 +6239,158 @@
|
||||
"vite": "^6.0.0 || ^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/coverage-v8": {
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.9.tgz",
|
||||
"integrity": "sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@bcoe/v8-coverage": "^1.0.2",
|
||||
"@vitest/utils": "4.1.9",
|
||||
"ast-v8-to-istanbul": "^1.0.0",
|
||||
"istanbul-lib-coverage": "^3.2.2",
|
||||
"istanbul-lib-report": "^3.0.1",
|
||||
"istanbul-reports": "^3.2.0",
|
||||
"magicast": "^0.5.2",
|
||||
"obug": "^2.1.1",
|
||||
"std-env": "^4.0.0-rc.1",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@vitest/browser": "4.1.9",
|
||||
"vitest": "4.1.9"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@vitest/browser": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz",
|
||||
"integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "4.1.9",
|
||||
"@vitest/utils": "4.1.9",
|
||||
"chai": "^6.2.2",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz",
|
||||
"integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "4.1.9",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.21"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"msw": "^2.4.9",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"msw": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz",
|
||||
"integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz",
|
||||
"integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "4.1.9",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz",
|
||||
"integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.9",
|
||||
"@vitest/utils": "4.1.9",
|
||||
"magic-string": "^0.30.21",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz",
|
||||
"integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz",
|
||||
"integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.9",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils/node_modules/convert-source-map": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@webassemblyjs/ast": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz",
|
||||
@@ -6678,6 +6859,35 @@
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/assertion-error": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/ast-v8-to-istanbul": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.4.tgz",
|
||||
"integrity": "sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/trace-mapping": "^0.3.31",
|
||||
"estree-walker": "^3.0.3",
|
||||
"js-tokens": "^10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ast-v8-to-istanbul/node_modules/js-tokens": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
|
||||
"integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/autoprefixer": {
|
||||
"version": "10.4.27",
|
||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz",
|
||||
@@ -7099,6 +7309,16 @@
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/chai": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "5.6.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
|
||||
@@ -8136,6 +8356,16 @@
|
||||
"node": ">=4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/estree-walker": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/esutils": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
|
||||
@@ -8196,6 +8426,16 @@
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/expect-type": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
||||
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/exponential-backoff": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz",
|
||||
@@ -8747,6 +8987,13 @@
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/html-escaper": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
|
||||
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/htmlparser2": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
|
||||
@@ -9287,6 +9534,64 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/istanbul-lib-report": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
|
||||
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"istanbul-lib-coverage": "^3.0.0",
|
||||
"make-dir": "^4.0.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/istanbul-lib-report/node_modules/make-dir": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
|
||||
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"semver": "^7.5.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/istanbul-lib-report/node_modules/supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-flag": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/istanbul-reports": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
|
||||
"integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"html-escaper": "^2.0.0",
|
||||
"istanbul-lib-report": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-worker": {
|
||||
"version": "27.5.1",
|
||||
"resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz",
|
||||
@@ -9308,7 +9613,6 @@
|
||||
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
@@ -9804,6 +10108,18 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/magicast": {
|
||||
"version": "0.5.3",
|
||||
"resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz",
|
||||
"integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.29.3",
|
||||
"@babel/types": "^7.29.0",
|
||||
"source-map-js": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/make-dir": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz",
|
||||
@@ -10612,6 +10928,20 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/obug": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz",
|
||||
"integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
"https://github.com/sponsors/sxzz",
|
||||
"https://opencollective.com/debug"
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/on-finished": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||
@@ -11009,6 +11339,13 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/pathe": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -11798,7 +12135,6 @@
|
||||
"integrity": "sha512-fDz1zJpd5GycprAbu4Q2PV/RprsRtKC/0z82z0JLgdytmcq0+ujJbJ/09bPGDxCLkKY3Np5cRAOcWiVkLXJURg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"chokidar": "^4.0.0",
|
||||
"immutable": "^5.0.2",
|
||||
@@ -12281,6 +12617,13 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/siginfo": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/signal-exit": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
||||
@@ -12527,6 +12870,13 @@
|
||||
"node": "^20.17.0 || >=22.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/stackback": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
@@ -12537,6 +12887,13 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/std-env": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
|
||||
"integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/stdin-discarder": {
|
||||
"version": "0.3.2",
|
||||
"resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.3.2.tgz",
|
||||
@@ -12669,7 +13026,6 @@
|
||||
"integrity": "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jridgewell/source-map": "^0.3.3",
|
||||
"acorn": "^8.15.0",
|
||||
@@ -12768,6 +13124,23 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyexec": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz",
|
||||
"integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.15",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
@@ -12785,6 +13158,16 @@
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyrainbow": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
|
||||
"integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
@@ -13173,6 +13556,97 @@
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz",
|
||||
"integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.1.9",
|
||||
"@vitest/mocker": "4.1.9",
|
||||
"@vitest/pretty-format": "4.1.9",
|
||||
"@vitest/runner": "4.1.9",
|
||||
"@vitest/snapshot": "4.1.9",
|
||||
"@vitest/spy": "4.1.9",
|
||||
"@vitest/utils": "4.1.9",
|
||||
"es-module-lexer": "^2.0.0",
|
||||
"expect-type": "^1.3.0",
|
||||
"magic-string": "^0.30.21",
|
||||
"obug": "^2.1.1",
|
||||
"pathe": "^2.0.3",
|
||||
"picomatch": "^4.0.3",
|
||||
"std-env": "^4.0.0-rc.1",
|
||||
"tinybench": "^2.9.0",
|
||||
"tinyexec": "^1.0.2",
|
||||
"tinyglobby": "^0.2.15",
|
||||
"tinyrainbow": "^3.1.0",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
|
||||
"why-is-node-running": "^2.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"vitest": "vitest.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@edge-runtime/vm": "*",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||
"@vitest/browser-playwright": "4.1.9",
|
||||
"@vitest/browser-preview": "4.1.9",
|
||||
"@vitest/browser-webdriverio": "4.1.9",
|
||||
"@vitest/coverage-istanbul": "4.1.9",
|
||||
"@vitest/coverage-v8": "4.1.9",
|
||||
"@vitest/ui": "4.1.9",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edge-runtime/vm": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentelemetry/api": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-playwright": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-preview": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-webdriverio": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/coverage-istanbul": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/coverage-v8": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/ui": {
|
||||
"optional": true
|
||||
},
|
||||
"happy-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"jsdom": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/watchpack": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz",
|
||||
@@ -13892,6 +14366,23 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/why-is-node-running": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"siginfo": "^2.0.0",
|
||||
"stackback": "0.0.2"
|
||||
},
|
||||
"bin": {
|
||||
"why-is-node-running": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/wildcard": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "loremind-web",
|
||||
"version": "0.16.1",
|
||||
"version": "0.16.2",
|
||||
"description": "LoreMind Frontend - Angular",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
@@ -8,6 +8,9 @@
|
||||
"build": "ng build",
|
||||
"watch": "ng build --watch --configuration development",
|
||||
"test": "ng test",
|
||||
"test:unit": "vitest run",
|
||||
"test:unit:watch": "vitest",
|
||||
"test:unit:coverage": "vitest run --coverage",
|
||||
"e2e": "playwright test",
|
||||
"e2e:ui": "playwright test --ui",
|
||||
"e2e:headed": "playwright test --headed",
|
||||
@@ -39,6 +42,8 @@
|
||||
"@emnapi/core": "^1.11.0",
|
||||
"@emnapi/runtime": "^1.11.0",
|
||||
"@playwright/test": "^1.59.1",
|
||||
"typescript": "~5.9.3"
|
||||
"@vitest/coverage-v8": "^4.1.9",
|
||||
"typescript": "~5.9.3",
|
||||
"vitest": "^4.1.9"
|
||||
}
|
||||
}
|
||||
|
||||
81
web/src/app/campaigns/campaign-tree.helper.spec.ts
Normal file
81
web/src/app/campaigns/campaign-tree.helper.spec.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildCampaignTree, CampaignTreeData } from './campaign-tree.helper';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
|
||||
// TranslateService factice : renvoie la clé telle quelle (suffisant pour vérifier
|
||||
// la structure de l'arbre sans dépendre des traductions).
|
||||
const t = { instant: (k: string) => k } as unknown as TranslateService;
|
||||
|
||||
function data(partial: Partial<CampaignTreeData> = {}): CampaignTreeData {
|
||||
return {
|
||||
arcs: [], chaptersByArc: {}, scenesByChapter: {},
|
||||
characters: [], npcs: [], randomTables: [], enemies: [],
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildCampaignTree', () => {
|
||||
it('produit les nœuds fixes (PNJ, ennemis, tables, ateliers, catalogues, import) quand vide', () => {
|
||||
const tree = buildCampaignTree('c1', data(), t);
|
||||
expect(tree.map((n) => n.id)).toEqual([
|
||||
'npcs-root', 'enemies-root', 'random-tables-root',
|
||||
'notebooks-root', 'item-catalogs-root', 'import-pdf-root',
|
||||
]);
|
||||
});
|
||||
|
||||
it('trie les arcs en ordre NUMÉRIQUE naturel (1, 2, 10)', () => {
|
||||
const arcs = [
|
||||
{ id: 'a', name: '10. Final' },
|
||||
{ id: 'b', name: '2. Voyage' },
|
||||
{ id: 'c', name: '1. Intro' },
|
||||
];
|
||||
const tree = buildCampaignTree('c1', data({ arcs: arcs as never }), t);
|
||||
const arcLabels = tree.filter((n) => n.id?.startsWith('arc-')).map((n) => n.label);
|
||||
expect(arcLabels).toEqual(['1. Intro', '2. Voyage', '10. Final']);
|
||||
});
|
||||
|
||||
it('imbrique chapitres et scènes et marque les chapitres à prérequis', () => {
|
||||
const tree = buildCampaignTree('camp', data({
|
||||
arcs: [{ id: 'a1', name: 'Arc', type: 'LINEAR' }] as never,
|
||||
chaptersByArc: { a1: [{ id: 'c1', name: 'Chap', prerequisites: [{}] }] } as never,
|
||||
scenesByChapter: { c1: [{ id: 's1', name: 'Scene' }] } as never,
|
||||
}), t);
|
||||
const arc = tree.find((n) => n.id === 'arc-a1')!;
|
||||
const chapter = arc.children![0];
|
||||
expect(chapter.id).toBe('chapter-c1');
|
||||
expect(chapter.meta).toBe('🔒'); // cadenas si prérequis
|
||||
expect(chapter.children![0].id).toBe('scene-s1');
|
||||
});
|
||||
|
||||
it('regroupe les PNJ par dossier et laisse les non classés à la racine', () => {
|
||||
const npcs = [
|
||||
{ id: 'n1', name: 'Alice', folder: 'Ville' },
|
||||
{ id: 'n2', name: 'Bob', folder: 'Ville' },
|
||||
{ id: 'n3', name: 'Carl' },
|
||||
];
|
||||
const tree = buildCampaignTree('c', data({ npcs: npcs as never }), t);
|
||||
const npcsRoot = tree.find((n) => n.id === 'npcs-root')!;
|
||||
expect(npcsRoot.meta).toBe('3');
|
||||
const folder = npcsRoot.children!.find((c) => c.id === 'npc-folder-Ville')!;
|
||||
expect(folder.children!.map((c) => c.label)).toEqual(['Alice', 'Bob']);
|
||||
expect(npcsRoot.children!.some((c) => c.id === 'npc-n3')).toBe(true);
|
||||
});
|
||||
|
||||
it("affiche le niveau de l'ennemi en méta", () => {
|
||||
const tree = buildCampaignTree('c', data({
|
||||
enemies: [{ id: 'e1', name: 'Gobelin', level: 3 }] as never,
|
||||
}), t);
|
||||
const enemiesRoot = tree.find((n) => n.id === 'enemies-root')!;
|
||||
expect(enemiesRoot.children![0].meta).toBe('Niv. 3');
|
||||
});
|
||||
|
||||
it('libelle « nouvelle quête » dans un arc HUB (vs « nouveau chapitre »)', () => {
|
||||
const hub = buildCampaignTree('c', data({ arcs: [{ id: 'a', name: 'Hub', type: 'HUB' }] as never }), t)
|
||||
.find((n) => n.id === 'arc-a')!;
|
||||
expect(hub.createActions![0].label).toBe('campaignTree.newQuest');
|
||||
|
||||
const linear = buildCampaignTree('c', data({ arcs: [{ id: 'a', name: 'Lin', type: 'LINEAR' }] as never }), t)
|
||||
.find((n) => n.id === 'arc-a')!;
|
||||
expect(linear.createActions![0].label).toBe('campaignTree.newChapter');
|
||||
});
|
||||
});
|
||||
@@ -329,7 +329,16 @@ export class GameSystemEditComponent implements OnInit {
|
||||
const valid = this.sections.filter(s => s.title.trim() || s.content.trim());
|
||||
if (valid.length === 0) return null;
|
||||
return valid
|
||||
.map(s => `## ${s.title.trim() || 'Sans titre'}\n${s.content.trim()}`)
|
||||
.map(s => {
|
||||
// Le "## " est RESERVE aux titres de section (parseMarkdown decoupe dessus,
|
||||
// tout comme le parser Java cote backend). Or le contenu genere par l'IA
|
||||
// contient souvent des sous-titres "## " : sans rien faire, ils seraient
|
||||
// re-interpretes comme de nouvelles sections au rechargement -> explosion
|
||||
// de sections + sections vides. On les retrograde donc en "### " (un niveau
|
||||
// plus bas) pour garder un round-trip stable. (### / #### ne collisionnent pas.)
|
||||
const content = s.content.trim().replace(/^##[ \t]+/gm, '### ');
|
||||
return `## ${s.title.trim() || 'Sans titre'}\n${content}`;
|
||||
})
|
||||
.join('\n\n');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { Observable, Subscriber } from 'rxjs';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { LanguageService } from './language.service';
|
||||
import { parseSseStream, sseFetch } from '../shared/sse.util';
|
||||
|
||||
/**
|
||||
* Un message d'une conversation IA (vue front).
|
||||
@@ -105,108 +106,43 @@ export class AiChatService {
|
||||
|
||||
/** Plumbing SSE mutualisé entre les endpoints (Lore / Campaign / Session). */
|
||||
private streamSse(endpoint: string, body: Record<string, unknown>): Observable<ChatStreamEvent> {
|
||||
return new Observable<ChatStreamEvent>((subscriber) => {
|
||||
const controller = new AbortController();
|
||||
|
||||
fetch(endpoint, {
|
||||
return sseFetch<ChatStreamEvent>(
|
||||
endpoint,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'text/event-stream',
|
||||
'X-User-Language': this.language.current
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (!response.ok || !response.body) {
|
||||
subscriber.error(new Error(`HTTP ${response.status}`));
|
||||
return;
|
||||
}
|
||||
await this.consumeSseStream(response.body, subscriber);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (controller.signal.aborted) return; // annulation volontaire, silencieuse
|
||||
subscriber.error(err);
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
});
|
||||
body: JSON.stringify(body)
|
||||
},
|
||||
(responseBody, subscriber) => this.consumeSseStream(responseBody, subscriber)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Consomme un ReadableStream SSE ligne par ligne.
|
||||
* Format attendu (un événement = un bloc séparé par `\n\n`) :
|
||||
* event: done (optionnel, défaut = 'message')
|
||||
* data: {...} (une ou plusieurs lignes, concaténées avec '\n')
|
||||
* <ligne vide> (séparateur d'événements)
|
||||
*/
|
||||
/** Mappe les événements SSE bruts vers les `ChatStreamEvent` typés. */
|
||||
private async consumeSseStream(
|
||||
body: ReadableStream<Uint8Array>,
|
||||
subscriber: { next: (e: ChatStreamEvent) => void; error: (e: unknown) => void; complete: () => void }
|
||||
subscriber: Subscriber<ChatStreamEvent>
|
||||
): Promise<void> {
|
||||
const reader = body.getReader();
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
let buffer = '';
|
||||
|
||||
// Événement SSE en cours de construction (accumulé entre lignes vides).
|
||||
let currentEvent: string | null = null;
|
||||
let currentData = '';
|
||||
|
||||
const dispatchCurrentEvent = () => {
|
||||
const eventName = currentEvent ?? 'message';
|
||||
// DEBUG jauge de contexte — à retirer une fois stabilisé.
|
||||
if (eventName !== 'message') {
|
||||
console.log('[AiChatService] SSE event:', eventName, 'data:', currentData);
|
||||
}
|
||||
if (eventName === 'error') {
|
||||
const message = this.safeParseMessage(currentData);
|
||||
subscriber.error(new Error(message));
|
||||
} else if (eventName === 'done') {
|
||||
subscriber.next({ type: 'done' });
|
||||
subscriber.complete();
|
||||
} else if (eventName === 'usage') {
|
||||
const usage = this.safeParseUsage(currentData);
|
||||
if (usage) subscriber.next({ type: 'usage', usage });
|
||||
} else {
|
||||
// Événement 'message' (défaut) : JSON {"token": "..."}
|
||||
const token = this.safeParseToken(currentData);
|
||||
if (token) subscriber.next({ type: 'token', value: token });
|
||||
}
|
||||
currentEvent = null;
|
||||
currentData = '';
|
||||
};
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
// On découpe par lignes ; la dernière (potentiellement incomplète) reste dans buffer.
|
||||
let newlineIdx: number;
|
||||
while ((newlineIdx = buffer.indexOf('\n')) >= 0) {
|
||||
const line = buffer.slice(0, newlineIdx).replace(/\r$/, '');
|
||||
buffer = buffer.slice(newlineIdx + 1);
|
||||
|
||||
if (line === '') {
|
||||
// Ligne vide = fin d'un événement SSE : on dispatch ce qu'on a accumulé.
|
||||
if (currentEvent !== null || currentData !== '') {
|
||||
dispatchCurrentEvent();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('event:')) {
|
||||
currentEvent = line.slice(6).trim();
|
||||
} else if (line.startsWith('data:')) {
|
||||
const chunk = line.slice(5).replace(/^ /, '');
|
||||
currentData = currentData ? `${currentData}\n${chunk}` : chunk;
|
||||
}
|
||||
// Autres champs SSE (id:, retry:) ignorés pour le MVP.
|
||||
await parseSseStream(body, ({ event, data }) => {
|
||||
if (event === 'error') {
|
||||
subscriber.error(new Error(this.safeParseMessage(data)));
|
||||
} else if (event === 'done') {
|
||||
subscriber.next({ type: 'done' });
|
||||
subscriber.complete();
|
||||
} else if (event === 'usage') {
|
||||
const usage = this.safeParseUsage(data);
|
||||
if (usage) subscriber.next({ type: 'usage', usage });
|
||||
} else {
|
||||
// Événement 'message' (défaut) : JSON {"token": "..."}
|
||||
const token = this.safeParseToken(data);
|
||||
if (token) subscriber.next({ type: 'token', value: token });
|
||||
}
|
||||
}
|
||||
});
|
||||
// Fin de stream côté réseau sans event: done explicite → on complete quand même.
|
||||
if (currentEvent !== null || currentData !== '') dispatchCurrentEvent();
|
||||
subscriber.complete();
|
||||
} catch (err) {
|
||||
subscriber.error(err);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { Observable, Subscriber } from 'rxjs';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import {
|
||||
CampaignImportApplyResult,
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
CampaignImportStreamEvent
|
||||
} from './campaign-import.model';
|
||||
import { LanguageService } from './language.service';
|
||||
import { parseSseStream, sseFetch } from '../shared/sse.util';
|
||||
|
||||
/**
|
||||
* Service HTTP pour l'import d'un PDF de campagne.
|
||||
@@ -21,31 +22,17 @@ export class CampaignImportService {
|
||||
constructor(private http: HttpClient, private translate: TranslateService, private language: LanguageService) {}
|
||||
|
||||
importStructureStream(campaignId: string, file: File): Observable<CampaignImportStreamEvent> {
|
||||
return new Observable<CampaignImportStreamEvent>((subscriber) => {
|
||||
const controller = new AbortController();
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
|
||||
fetch(`/api/campaigns/${campaignId}/import-structure/stream`, {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
return sseFetch<CampaignImportStreamEvent>(
|
||||
`/api/campaigns/${campaignId}/import-structure/stream`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Accept': 'text/event-stream', 'X-User-Language': this.language.current },
|
||||
body: form,
|
||||
signal: controller.signal
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (!response.ok || !response.body) {
|
||||
subscriber.error(new Error(`HTTP ${response.status}`));
|
||||
return;
|
||||
}
|
||||
await this.consumeSse(response.body, subscriber);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (controller.signal.aborted) return;
|
||||
subscriber.error(err);
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
});
|
||||
body: form
|
||||
},
|
||||
(body, subscriber) => this.consumeSse(body, subscriber)
|
||||
);
|
||||
}
|
||||
|
||||
applyStructure(campaignId: string, proposal: CampaignImportProposal): Observable<CampaignImportApplyResult> {
|
||||
@@ -53,39 +40,33 @@ export class CampaignImportService {
|
||||
`/api/campaigns/${campaignId}/import-structure/apply`, proposal);
|
||||
}
|
||||
|
||||
/** Décode un ReadableStream SSE et émet les évènements d'import typés. */
|
||||
/** Mappe les évènements SSE bruts vers les évènements d'import typés. */
|
||||
private async consumeSse(
|
||||
body: ReadableStream<Uint8Array>,
|
||||
subscriber: { next: (e: CampaignImportStreamEvent) => void; error: (e: unknown) => void; complete: () => void }
|
||||
subscriber: Subscriber<CampaignImportStreamEvent>
|
||||
): Promise<void> {
|
||||
const reader = body.getReader();
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
let buffer = '';
|
||||
let currentEvent: string | null = null;
|
||||
let currentData = '';
|
||||
// Le flux s'est-il terminé PROPREMENT (évènement done ou error reçu) ?
|
||||
// Sans ce suivi, une connexion coupée en plein import (timeout serveur,
|
||||
// proxy, Core redémarré) terminait l'Observable en silence : barre de
|
||||
// progression figée et aucun message pour l'utilisateur.
|
||||
let terminated = false;
|
||||
|
||||
const dispatch = () => {
|
||||
const name = currentEvent ?? 'message';
|
||||
if (name === 'error') {
|
||||
const dispatch = ({ event, data }: { event: string; data: string }) => {
|
||||
if (event === 'error') {
|
||||
let message = this.translate.instant('services.importFailed');
|
||||
try { message = (JSON.parse(currentData) as { message?: string }).message ?? message; } catch { /* défaut */ }
|
||||
try { message = (JSON.parse(data) as { message?: string }).message ?? message; } catch { /* défaut */ }
|
||||
terminated = true;
|
||||
subscriber.error(new Error(message));
|
||||
} else if (name === 'status') {
|
||||
} else if (event === 'status') {
|
||||
// Message d'attente lisible (fournisseur saturé, morceau re-découpé…).
|
||||
try {
|
||||
const obj = JSON.parse(currentData) as { message?: string };
|
||||
const obj = JSON.parse(data) as { message?: string };
|
||||
if (obj.message) subscriber.next({ type: 'status', message: obj.message });
|
||||
} catch { /* bloc malformé ignoré */ }
|
||||
} else if (name === 'progress' || name === 'done') {
|
||||
} else if (event === 'progress' || event === 'done') {
|
||||
try {
|
||||
const obj = JSON.parse(currentData);
|
||||
if (name === 'done') {
|
||||
const obj = JSON.parse(data);
|
||||
if (event === 'done') {
|
||||
terminated = true;
|
||||
subscriber.next({ type: 'done', arcs: obj.arcs ?? [], npcs: obj.npcs ?? [] });
|
||||
subscriber.complete();
|
||||
@@ -94,32 +75,10 @@ export class CampaignImportService {
|
||||
}
|
||||
} catch { /* bloc malformé ignoré */ }
|
||||
}
|
||||
currentEvent = null;
|
||||
currentData = '';
|
||||
};
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
let idx: number;
|
||||
while ((idx = buffer.indexOf('\n')) >= 0) {
|
||||
const line = buffer.slice(0, idx).replace(/\r$/, '');
|
||||
buffer = buffer.slice(idx + 1);
|
||||
if (line === '') {
|
||||
if (currentEvent !== null || currentData !== '') dispatch();
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('event:')) {
|
||||
currentEvent = line.slice(6).trim();
|
||||
} else if (line.startsWith('data:')) {
|
||||
const chunk = line.slice(5).replace(/^ /, '');
|
||||
currentData = currentData ? `${currentData}\n${chunk}` : chunk;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (currentEvent !== null || currentData !== '') dispatch();
|
||||
await parseSseStream(body, dispatch);
|
||||
if (!terminated) {
|
||||
subscriber.error(new Error(
|
||||
this.translate.instant('services.importInterrupted')));
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { Observable, Subscriber } from 'rxjs';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { GameSystem, GameSystemCreate, RulesImportResponse, RulesImportStreamEvent } from './game-system.model';
|
||||
import { LanguageService } from './language.service';
|
||||
import { parseSseStream, sseFetch } from '../shared/sse.util';
|
||||
|
||||
/**
|
||||
* Service HTTP pour les GameSystems (systèmes de JDR).
|
||||
@@ -57,66 +58,46 @@ export class GameSystemService {
|
||||
* subscription annule le fetch (AbortController).
|
||||
*/
|
||||
importRulesStream(file: File): Observable<RulesImportStreamEvent> {
|
||||
return new Observable<RulesImportStreamEvent>((subscriber) => {
|
||||
const controller = new AbortController();
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
|
||||
fetch(`${this.apiUrl}/import-rules/stream`, {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
return sseFetch<RulesImportStreamEvent>(
|
||||
`${this.apiUrl}/import-rules/stream`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Accept': 'text/event-stream', 'X-User-Language': this.language.current },
|
||||
body: form,
|
||||
signal: controller.signal
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (!response.ok || !response.body) {
|
||||
subscriber.error(new Error(`HTTP ${response.status}`));
|
||||
return;
|
||||
}
|
||||
await this.consumeImportSse(response.body, subscriber);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (controller.signal.aborted) return;
|
||||
subscriber.error(err);
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
});
|
||||
body: form
|
||||
},
|
||||
(body, subscriber) => this.consumeImportSse(body, subscriber)
|
||||
);
|
||||
}
|
||||
|
||||
/** Décode un ReadableStream SSE et émet les évènements d'import typés. */
|
||||
/** Mappe les évènements SSE bruts vers les évènements d'import typés. */
|
||||
private async consumeImportSse(
|
||||
body: ReadableStream<Uint8Array>,
|
||||
subscriber: { next: (e: RulesImportStreamEvent) => void; error: (e: unknown) => void; complete: () => void }
|
||||
subscriber: Subscriber<RulesImportStreamEvent>
|
||||
): Promise<void> {
|
||||
const reader = body.getReader();
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
let buffer = '';
|
||||
let currentEvent: string | null = null;
|
||||
let currentData = '';
|
||||
// Le flux s'est-il terminé PROPREMENT (évènement done ou error reçu) ?
|
||||
// Sans ce suivi, une connexion coupée en plein import (timeout serveur,
|
||||
// proxy, Core redémarré) terminait l'Observable en silence : barre de
|
||||
// progression figée et aucun message pour l'utilisateur.
|
||||
let terminated = false;
|
||||
|
||||
const dispatch = () => {
|
||||
const name = currentEvent ?? 'message';
|
||||
if (name === 'error') {
|
||||
const dispatch = ({ event, data }: { event: string; data: string }) => {
|
||||
if (event === 'error') {
|
||||
let message = this.translate.instant('services.importFailed');
|
||||
try { message = (JSON.parse(currentData) as { message?: string }).message ?? message; } catch { /* garde le défaut */ }
|
||||
try { message = (JSON.parse(data) as { message?: string }).message ?? message; } catch { /* garde le défaut */ }
|
||||
terminated = true;
|
||||
subscriber.error(new Error(message));
|
||||
} else if (name === 'status') {
|
||||
} else if (event === 'status') {
|
||||
// Message d'attente lisible (fournisseur saturé, morceau re-découpé…).
|
||||
try {
|
||||
const obj = JSON.parse(currentData) as { message?: string };
|
||||
const obj = JSON.parse(data) as { message?: string };
|
||||
if (obj.message) subscriber.next({ type: 'status', message: obj.message });
|
||||
} catch { /* bloc malformé ignoré */ }
|
||||
} else if (name === 'progress' || name === 'done') {
|
||||
} else if (event === 'progress' || event === 'done') {
|
||||
try {
|
||||
const obj = JSON.parse(currentData);
|
||||
if (name === 'done') {
|
||||
const obj = JSON.parse(data);
|
||||
if (event === 'done') {
|
||||
terminated = true;
|
||||
subscriber.next({ type: 'done', ...obj });
|
||||
subscriber.complete();
|
||||
@@ -125,32 +106,10 @@ export class GameSystemService {
|
||||
}
|
||||
} catch { /* bloc malformé ignoré */ }
|
||||
}
|
||||
currentEvent = null;
|
||||
currentData = '';
|
||||
};
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
let idx: number;
|
||||
while ((idx = buffer.indexOf('\n')) >= 0) {
|
||||
const line = buffer.slice(0, idx).replace(/\r$/, '');
|
||||
buffer = buffer.slice(idx + 1);
|
||||
if (line === '') {
|
||||
if (currentEvent !== null || currentData !== '') dispatch();
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('event:')) {
|
||||
currentEvent = line.slice(6).trim();
|
||||
} else if (line.startsWith('data:')) {
|
||||
const chunk = line.slice(5).replace(/^ /, '');
|
||||
currentData = currentData ? `${currentData}\n${chunk}` : chunk;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (currentEvent !== null || currentData !== '') dispatch();
|
||||
await parseSseStream(body, dispatch);
|
||||
if (!terminated) {
|
||||
subscriber.error(new Error(
|
||||
this.translate.instant('services.importInterrupted')));
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Observable } from 'rxjs';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { Notebook, NotebookArchive, NotebookDetail, NotebookSource, NotebookChatEvent } from './notebook.model';
|
||||
import { LanguageService } from './language.service';
|
||||
import { parseSseStream } from '../shared/sse.util';
|
||||
|
||||
/**
|
||||
* Service des notebooks (atelier RAG) : CRUD, upload/indexation de sources,
|
||||
@@ -88,20 +89,13 @@ export class NotebookService {
|
||||
this.zone.run(() => subscriber.complete());
|
||||
return;
|
||||
}
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
let buffer = '';
|
||||
let currentEvent: string | null = null;
|
||||
let currentData = '';
|
||||
|
||||
const dispatch = () => {
|
||||
const name = currentEvent ?? 'message';
|
||||
if (name === 'token') {
|
||||
try { emit({ type: 'token', value: (JSON.parse(currentData) as { token?: string }).token ?? '' }); }
|
||||
await parseSseStream(response.body, ({ event, data }) => {
|
||||
if (event === 'token') {
|
||||
try { emit({ type: 'token', value: (JSON.parse(data) as { token?: string }).token ?? '' }); }
|
||||
catch { /* ignore */ }
|
||||
} else if (name === 'sources') {
|
||||
} else if (event === 'sources') {
|
||||
try {
|
||||
const o = JSON.parse(currentData) as { sources?: { source_id?: string; page?: number | null; score?: number }[] };
|
||||
const o = JSON.parse(data) as { sources?: { source_id?: string; page?: number | null; score?: number }[] };
|
||||
emit({
|
||||
type: 'sources',
|
||||
sources: (o.sources ?? []).map(s => ({
|
||||
@@ -109,39 +103,19 @@ export class NotebookService {
|
||||
}))
|
||||
});
|
||||
} catch { /* ignore */ }
|
||||
} else if (name === 'progress') {
|
||||
} else if (event === 'progress') {
|
||||
try {
|
||||
const o = JSON.parse(currentData) as { current?: number; total?: number };
|
||||
const o = JSON.parse(data) as { current?: number; total?: number };
|
||||
emit({ type: 'progress', current: o.current ?? 0, total: o.total ?? 0 });
|
||||
} catch { /* ignore */ }
|
||||
} else if (name === 'done') {
|
||||
} else if (event === 'done') {
|
||||
emit({ type: 'done' });
|
||||
} else if (name === 'error') {
|
||||
} else if (event === 'error') {
|
||||
let msg = 'Erreur du chat.';
|
||||
try { msg = (JSON.parse(currentData) as { message?: string }).message ?? msg; } catch { /* garde */ }
|
||||
try { msg = (JSON.parse(data) as { message?: string }).message ?? msg; } catch { /* garde */ }
|
||||
emit({ type: 'error', message: msg });
|
||||
}
|
||||
currentEvent = null;
|
||||
currentData = '';
|
||||
};
|
||||
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
let idx: number;
|
||||
while ((idx = buffer.indexOf('\n')) >= 0) {
|
||||
const line = buffer.slice(0, idx).replace(/\r$/, '');
|
||||
buffer = buffer.slice(idx + 1);
|
||||
if (line === '') { if (currentEvent !== null || currentData !== '') dispatch(); continue; }
|
||||
if (line.startsWith('event:')) currentEvent = line.slice(6).trim();
|
||||
else if (line.startsWith('data:')) {
|
||||
const chunk = line.slice(5).replace(/^ /, '');
|
||||
currentData = currentData ? `${currentData}\n${chunk}` : chunk;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (currentEvent !== null || currentData !== '') dispatch();
|
||||
});
|
||||
this.zone.run(() => subscriber.complete());
|
||||
} catch (err) {
|
||||
if ((err as Error).name !== 'AbortError') {
|
||||
|
||||
61
web/src/app/shared/dice.utils.spec.ts
Normal file
61
web/src/app/shared/dice.utils.spec.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { DiceUtils } from './dice.utils';
|
||||
|
||||
describe('DiceUtils.parse', () => {
|
||||
it('parse une formule complète', () => {
|
||||
expect(DiceUtils.parse('2d6')).toEqual({ count: 2, faces: 6 });
|
||||
});
|
||||
|
||||
it('compte par défaut = 1 ("d20")', () => {
|
||||
expect(DiceUtils.parse('d20')).toEqual({ count: 1, faces: 20 });
|
||||
});
|
||||
|
||||
it('tolère espaces et casse', () => {
|
||||
expect(DiceUtils.parse(' 1 D 100 ')).toEqual({ count: 1, faces: 100 });
|
||||
});
|
||||
|
||||
it('rejette les formules invalides ou hors bornes', () => {
|
||||
for (const f of ['', null, undefined, 'abc', '2x6', '0d6', '101d6', '1d1', '1d20000']) {
|
||||
expect(DiceUtils.parse(f as string)).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('DiceUtils.roll', () => {
|
||||
it('renvoie des jets dans la plage et un total = somme', () => {
|
||||
const r = DiceUtils.roll('3d6');
|
||||
expect(r).not.toBeNull();
|
||||
expect(r!.rolls).toHaveLength(3);
|
||||
for (const v of r!.rolls) {
|
||||
expect(v).toBeGreaterThanOrEqual(1);
|
||||
expect(v).toBeLessThanOrEqual(6);
|
||||
}
|
||||
expect(r!.total).toBe(r!.rolls.reduce((a, b) => a + b, 0));
|
||||
expect(r!.total).toBeGreaterThanOrEqual(3);
|
||||
expect(r!.total).toBeLessThanOrEqual(18);
|
||||
});
|
||||
|
||||
it('null si formule invalide', () => {
|
||||
expect(DiceUtils.roll('nope')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DiceUtils.totalRange', () => {
|
||||
it('2d6 -> {min:2, max:12}', () => {
|
||||
expect(DiceUtils.totalRange('2d6')).toEqual({ min: 2, max: 12 });
|
||||
});
|
||||
|
||||
it('null si invalide', () => {
|
||||
expect(DiceUtils.totalRange('x')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DiceUtils.randomInt', () => {
|
||||
it('reste dans [min, max] sur de nombreux tirages', () => {
|
||||
for (let i = 0; i < 200; i++) {
|
||||
const v = DiceUtils.randomInt(1, 6);
|
||||
expect(v).toBeGreaterThanOrEqual(1);
|
||||
expect(v).toBeLessThanOrEqual(6);
|
||||
}
|
||||
});
|
||||
});
|
||||
112
web/src/app/shared/sse.util.spec.ts
Normal file
112
web/src/app/shared/sse.util.spec.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { parseSseStream, sseFetch, SseEvent } from './sse.util';
|
||||
|
||||
/** Construit un ReadableStream à partir d'une chaîne (un seul chunk). */
|
||||
function streamFrom(text: string): ReadableStream<Uint8Array> {
|
||||
const bytes = new TextEncoder().encode(text);
|
||||
return new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(bytes);
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function collect(text: string): Promise<SseEvent[]> {
|
||||
const events: SseEvent[] = [];
|
||||
await parseSseStream(streamFrom(text), (e) => events.push(e));
|
||||
return events;
|
||||
}
|
||||
|
||||
describe('parseSseStream', () => {
|
||||
it('parse un événement nommé avec sa data', async () => {
|
||||
expect(await collect('event: token\ndata: {"token":"hi"}\n\n'))
|
||||
.toEqual([{ event: 'token', data: '{"token":"hi"}' }]);
|
||||
});
|
||||
|
||||
it("nomme l'événement par défaut 'message'", async () => {
|
||||
expect(await collect('data: {"token":"x"}\n\n'))
|
||||
.toEqual([{ event: 'message', data: '{"token":"x"}' }]);
|
||||
});
|
||||
|
||||
it('sépare plusieurs événements', async () => {
|
||||
const events = await collect('event: a\ndata: 1\n\nevent: b\ndata: 2\n\n');
|
||||
expect(events).toEqual([
|
||||
{ event: 'a', data: '1' },
|
||||
{ event: 'b', data: '2' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('concatène les lignes data multiples avec des \\n', async () => {
|
||||
const events = await collect('data: ligne1\ndata: ligne2\n\n');
|
||||
expect(events[0].data).toBe('ligne1\nligne2');
|
||||
});
|
||||
|
||||
it('gère les fins de ligne CRLF', async () => {
|
||||
expect(await collect('event: x\r\ndata: y\r\n\r\n'))
|
||||
.toEqual([{ event: 'x', data: 'y' }]);
|
||||
});
|
||||
|
||||
it('ignore les lignes de commentaire keep-alive (`: ...`)', async () => {
|
||||
expect(await collect(': keep-alive\n\ndata: ok\n\n'))
|
||||
.toEqual([{ event: 'message', data: 'ok' }]);
|
||||
});
|
||||
|
||||
it('dispatche un bloc résiduel en fin de flux sans ligne vide finale', async () => {
|
||||
// Lignes terminées par \n mais pas de ligne vide séparatrice finale.
|
||||
expect(await collect('event: done\ndata: {}\n'))
|
||||
.toEqual([{ event: 'done', data: '{}' }]);
|
||||
});
|
||||
|
||||
it('ne dispatche rien sur un flux vide', async () => {
|
||||
expect(await collect('')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sseFetch', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("consomme le corps et expose les évènements jusqu'à complétion", async () => {
|
||||
const body = streamFrom('event: done\ndata: {}\n\n');
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response(body, { status: 200 })));
|
||||
|
||||
const seen: string[] = [];
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
sseFetch<string>('/x', { method: 'POST' }, async (b, sub) => {
|
||||
await parseSseStream(b, (e) => seen.push(e.event));
|
||||
sub.next('mapped');
|
||||
sub.complete();
|
||||
}).subscribe({ next: (v) => seen.push(v), complete: resolve, error: reject });
|
||||
});
|
||||
|
||||
expect(seen).toContain('done');
|
||||
expect(seen).toContain('mapped');
|
||||
});
|
||||
|
||||
it('émet une erreur HTTP si la réponse est non-ok', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response('', { status: 500 })));
|
||||
|
||||
await expect(
|
||||
new Promise<void>((resolve, reject) => {
|
||||
sseFetch('/x', {}, async () => undefined).subscribe({
|
||||
error: reject,
|
||||
complete: resolve,
|
||||
});
|
||||
}),
|
||||
).rejects.toThrow('HTTP 500');
|
||||
});
|
||||
|
||||
it('annule le fetch quand on se désabonne', async () => {
|
||||
let abortedSignal: AbortSignal | undefined;
|
||||
vi.stubGlobal('fetch', vi.fn((_url: string, init: RequestInit) => {
|
||||
abortedSignal = init.signal as AbortSignal;
|
||||
return new Promise(() => { /* ne résout jamais : on teste l'annulation */ });
|
||||
}));
|
||||
|
||||
const sub = sseFetch('/x', {}, async () => undefined).subscribe();
|
||||
sub.unsubscribe();
|
||||
expect(abortedSignal?.aborted).toBe(true);
|
||||
});
|
||||
});
|
||||
110
web/src/app/shared/sse.util.ts
Normal file
110
web/src/app/shared/sse.util.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { Observable, Subscriber } from 'rxjs';
|
||||
|
||||
/**
|
||||
* Plumbing SSE mutualisé entre les services qui streament depuis le backend
|
||||
* (chat IA, import de campagne, import de règles, chat d'atelier).
|
||||
*
|
||||
* On n'utilise pas `EventSource` (API navigateur native) : elle ne supporte que
|
||||
* GET sans body, alors qu'on a besoin de POST (+ multipart). On fait donc un
|
||||
* `fetch()` dont on décode le `ReadableStream` ligne par ligne.
|
||||
*/
|
||||
|
||||
/** Un événement SSE complet (bloc séparé par une ligne vide). */
|
||||
export interface SseEvent {
|
||||
/** Nom de l'événement (`event:` du flux), ou `'message'` par défaut. */
|
||||
event: string;
|
||||
/** Charge utile (`data:`) ; les lignes multiples sont concaténées par `\n`. */
|
||||
data: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Décode un `ReadableStream` SSE et invoque `onEvent` pour chaque bloc complet.
|
||||
*
|
||||
* Format attendu (un événement = un bloc terminé par une ligne vide) :
|
||||
* event: done (optionnel, défaut = 'message')
|
||||
* data: {...} (une ou plusieurs lignes, concaténées avec '\n')
|
||||
* <ligne vide> (séparateur d'événements)
|
||||
*
|
||||
* Gère le buffer partiel entre deux lectures, les fins de ligne `\r\n`, et
|
||||
* dispatche un éventuel bloc résiduel en fin de flux (réseau coupé sans ligne
|
||||
* vide finale). Ne complète/erreur PAS d'observable : l'appelant mappe chaque
|
||||
* `SseEvent` vers son type métier et décide de la terminaison.
|
||||
*/
|
||||
export async function parseSseStream(
|
||||
body: ReadableStream<Uint8Array>,
|
||||
onEvent: (ev: SseEvent) => void
|
||||
): Promise<void> {
|
||||
const reader = body.getReader();
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
let buffer = '';
|
||||
let currentEvent: string | null = null;
|
||||
let currentData = '';
|
||||
|
||||
const flush = () => {
|
||||
if (currentEvent === null && currentData === '') return;
|
||||
onEvent({ event: currentEvent ?? 'message', data: currentData });
|
||||
currentEvent = null;
|
||||
currentData = '';
|
||||
};
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
// On découpe par lignes ; la dernière (potentiellement incomplète) reste dans buffer.
|
||||
let newlineIdx: number;
|
||||
while ((newlineIdx = buffer.indexOf('\n')) >= 0) {
|
||||
const line = buffer.slice(0, newlineIdx).replace(/\r$/, '');
|
||||
buffer = buffer.slice(newlineIdx + 1);
|
||||
|
||||
if (line === '') {
|
||||
flush(); // ligne vide = fin d'un événement SSE
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('event:')) {
|
||||
currentEvent = line.slice(6).trim();
|
||||
} else if (line.startsWith('data:')) {
|
||||
const chunk = line.slice(5).replace(/^ /, '');
|
||||
currentData = currentData ? `${currentData}\n${chunk}` : chunk;
|
||||
}
|
||||
// Autres champs SSE (id:, retry:) ignorés.
|
||||
}
|
||||
}
|
||||
// Fin de stream sans ligne vide finale : on dispatche le dernier bloc accumulé.
|
||||
flush();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ouvre un flux SSE via `fetch()` et expose ses événements dans un `Observable`.
|
||||
*
|
||||
* Annuler la souscription annule proprement le fetch (AbortController) ; une
|
||||
* erreur réseau survenue APRÈS une annulation volontaire est ignorée. Le rappel
|
||||
* `consume` reçoit le corps de la réponse et le subscriber : il mappe les
|
||||
* événements bruts vers le type métier `T` et complète/erreur le subscriber
|
||||
* (typiquement via {@link parseSseStream}).
|
||||
*/
|
||||
export function sseFetch<T>(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
consume: (body: ReadableStream<Uint8Array>, subscriber: Subscriber<T>) => Promise<void>
|
||||
): Observable<T> {
|
||||
return new Observable<T>((subscriber) => {
|
||||
const controller = new AbortController();
|
||||
|
||||
fetch(url, { ...init, signal: controller.signal })
|
||||
.then(async (response) => {
|
||||
if (!response.ok || !response.body) {
|
||||
subscriber.error(new Error(`HTTP ${response.status}`));
|
||||
return;
|
||||
}
|
||||
await consume(response.body, subscriber);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (controller.signal.aborted) return; // annulation volontaire, silencieuse
|
||||
subscriber.error(err);
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
});
|
||||
}
|
||||
@@ -30,6 +30,8 @@
|
||||
},
|
||||
"exclude": [
|
||||
"e2e/**/*",
|
||||
"playwright.config.ts"
|
||||
"playwright.config.ts",
|
||||
"vitest.config.ts",
|
||||
"src/**/*.spec.ts"
|
||||
]
|
||||
}
|
||||
|
||||
40
web/vitest.config.ts
Normal file
40
web/vitest.config.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
/**
|
||||
* Runner des tests UNITAIRES de logique PURE (TypeScript sans TestBed Angular) :
|
||||
* helpers/utils et services dont on teste les fonctions pures. Exécution en Node
|
||||
* (rapide, pas de navigateur). Les specs sont en `*.spec.ts` à côté du code.
|
||||
*
|
||||
* Lancement : `npm run test:unit` ; couverture : `npm run test:unit:coverage`
|
||||
* (rapport HTML dans coverage/ — l'équivalent JaCoCo).
|
||||
*
|
||||
* Hors du périmètre `ng build` (les .spec.ts sont exclus dans tsconfig.json).
|
||||
*/
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['src/**/*.spec.ts'],
|
||||
globals: true,
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'html'],
|
||||
// Périmètre de couverture = les fichiers effectivement couverts par des
|
||||
// specs. Ajouter ici chaque module au fur et à mesure qu'on le teste.
|
||||
include: [
|
||||
'src/app/shared/sse.util.ts',
|
||||
'src/app/shared/dice.utils.ts',
|
||||
'src/app/campaigns/campaign-tree.helper.ts',
|
||||
],
|
||||
// Plancher ANTI-RÉGRESSION (sous les valeurs actuelles : ~75% lignes).
|
||||
// campaign-tree.helper tire la moyenne vers le bas (loadCampaignTreeData /
|
||||
// buildCampaignSidebarConfig nécessitent des services Angular → non
|
||||
// unit-testables ici). À remonter à mesure que la couverture progresse.
|
||||
thresholds: {
|
||||
statements: 70,
|
||||
lines: 70,
|
||||
branches: 70,
|
||||
functions: 45,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user