feat(borne): modale allergenes generale (14 INCO) sur carte et fiche + harnais tests JS (P3) (#36)
All checks were successful
CI / secret-scan (push) Successful in 7s
CI / php-lint (push) Successful in 18s
CI / static-tests (push) Successful in 42s
CI / js-tests (push) Successful in 18s
CI / auto-merge (push) Has been skipped

This commit is contained in:
Corentin JOGUET 2026-06-17 12:10:46 +02:00
parent ed392d4c14
commit 1ecd78324c
12 changed files with 1015 additions and 4 deletions

View file

@ -142,14 +142,39 @@ jobs:
# du correctif : plus aucun skip silencieux des chemins securite.
php phpunit.phar -c phpunit.xml --fail-on-skipped
js-tests:
# Tests du front borne (kiosk) : node:test + jsdom, sans navigateur.
# GARDE : ne s'active que si package.json + tests/js/ existent.
runs-on: docker
steps:
- uses: actions/checkout@v4
- name: Install Node.js 20
run: |
set -eu
# Node 20 epingle via NodeSource (self-contained, comme les .phar/gitleaks)
# plutot que l'apt bookworm (18.x, limite basse pour jsdom). Reproductible.
apt-get update -qq && apt-get install -y -qq curl ca-certificates >/dev/null
curl -fsSL https://deb.nodesource.com/setup_20.x | bash - >/dev/null
apt-get install -y -qq nodejs >/dev/null
node --version && npm --version
- name: Install deps + run kiosk JS tests
run: |
set -eu
if [ ! -f package.json ] || [ ! -d tests/js ]; then
echo "JS tests skipped: no package.json + tests/js/ yet"
exit 0
fi
npm ci
npm run test:js
auto-merge:
# Fusion automatique OPT-IN : poser le label `auto-merge` sur la PR.
# Ne s'execute que si les 3 checks passent (needs).
# Ne s'execute que si tous les checks requis passent (needs).
# IMPORTANT : le filtrage par label se fait DANS le step via l'API, pas dans
# `if:` — l'expression contains(github.event.pull_request.labels.*.name, ...)
# de Forgejo n'est pas fiable (elle s'evalue a vrai meme sans label, ce qui
# fusionnait toute PR verte). La verification shell sur l'API est le vrai gate.
needs: [secret-scan, php-lint, static-tests]
needs: [secret-scan, php-lint, static-tests, js-tests]
if: github.event_name == 'pull_request'
runs-on: docker
steps:

545
package-lock.json generated Executable file
View file

@ -0,0 +1,545 @@
{
"name": "wakdo",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wakdo",
"version": "0.0.0",
"devDependencies": {
"jsdom": "^26.0.0"
}
},
"node_modules/@asamuzakjp/css-color": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
"integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@csstools/css-calc": "^2.1.3",
"@csstools/css-color-parser": "^3.0.9",
"@csstools/css-parser-algorithms": "^3.0.4",
"@csstools/css-tokenizer": "^3.0.3",
"lru-cache": "^10.4.3"
}
},
"node_modules/@csstools/color-helpers": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
"integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"engines": {
"node": ">=18"
}
},
"node_modules/@csstools/css-calc": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz",
"integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^3.0.5",
"@csstools/css-tokenizer": "^3.0.4"
}
},
"node_modules/@csstools/css-color-parser": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz",
"integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"dependencies": {
"@csstools/color-helpers": "^5.1.0",
"@csstools/css-calc": "^2.1.4"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^3.0.5",
"@csstools/css-tokenizer": "^3.0.4"
}
},
"node_modules/@csstools/css-parser-algorithms": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
"integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@csstools/css-tokenizer": "^3.0.4"
}
},
"node_modules/@csstools/css-tokenizer": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
"integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/cssstyle": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
"integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^3.2.0",
"rrweb-cssom": "^0.8.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/data-urls": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
"integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==",
"dev": true,
"license": "MIT",
"dependencies": {
"whatwg-mimetype": "^4.0.0",
"whatwg-url": "^14.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"dev": true,
"license": "MIT"
},
"node_modules/entities": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/html-encoding-sniffer": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
"integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"whatwg-encoding": "^3.1.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/http-proxy-agent": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
"integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
"dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.0",
"debug": "^4.3.4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"dev": true,
"license": "MIT"
},
"node_modules/jsdom": {
"version": "26.1.0",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz",
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
"dev": true,
"license": "MIT",
"dependencies": {
"cssstyle": "^4.2.1",
"data-urls": "^5.0.0",
"decimal.js": "^10.5.0",
"html-encoding-sniffer": "^4.0.0",
"http-proxy-agent": "^7.0.2",
"https-proxy-agent": "^7.0.6",
"is-potential-custom-element-name": "^1.0.1",
"nwsapi": "^2.2.16",
"parse5": "^7.2.1",
"rrweb-cssom": "^0.8.0",
"saxes": "^6.0.0",
"symbol-tree": "^3.2.4",
"tough-cookie": "^5.1.1",
"w3c-xmlserializer": "^5.0.0",
"webidl-conversions": "^7.0.0",
"whatwg-encoding": "^3.1.1",
"whatwg-mimetype": "^4.0.0",
"whatwg-url": "^14.1.1",
"ws": "^8.18.0",
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"canvas": "^3.0.0"
},
"peerDependenciesMeta": {
"canvas": {
"optional": true
}
}
},
"node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"dev": true,
"license": "ISC"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/nwsapi": {
"version": "2.2.24",
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz",
"integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==",
"dev": true,
"license": "MIT"
},
"node_modules/parse5": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
"dev": true,
"license": "MIT",
"dependencies": {
"entities": "^6.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/rrweb-cssom": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
"integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==",
"dev": true,
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"dev": true,
"license": "MIT"
},
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
"dev": true,
"license": "ISC",
"dependencies": {
"xmlchars": "^2.2.0"
},
"engines": {
"node": ">=v12.22.7"
}
},
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
"dev": true,
"license": "MIT"
},
"node_modules/tldts": {
"version": "6.1.86",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
"integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"tldts-core": "^6.1.86"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "6.1.86",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",
"integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
"dev": true,
"license": "MIT"
},
"node_modules/tough-cookie": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
"integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"tldts": "^6.1.32"
},
"engines": {
"node": ">=16"
}
},
"node_modules/tr46": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
"integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
"dev": true,
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
"dev": true,
"license": "MIT",
"dependencies": {
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/webidl-conversions": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/whatwg-encoding": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
"integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
"deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
"dev": true,
"license": "MIT",
"dependencies": {
"iconv-lite": "0.6.3"
},
"engines": {
"node": ">=18"
}
},
"node_modules/whatwg-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",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/whatwg-mimetype": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/whatwg-url": {
"version": "14.2.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
"integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"tr46": "^5.1.0",
"webidl-conversions": "^7.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/ws": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/xmlchars": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"dev": true,
"license": "MIT"
}
}
}

12
package.json Executable file
View file

@ -0,0 +1,12 @@
{
"name": "wakdo",
"version": "0.0.0",
"private": true,
"description": "Wakdo - tests front borne (kiosk). Back-office PHP teste via PHPUnit (phpunit.phar). NB: pas de \"type\":\"module\" a la racine -> les .js du depot (hooks .claude, _byan, bin) restent CommonJS. L'ESM est declare localement la ou il s'applique (src/public/borne/assets/js, tests/js).",
"scripts": {
"test:js": "node --test tests/js/"
},
"devDependencies": {
"jsdom": "^26.0.0"
}
}

View file

@ -1437,6 +1437,122 @@ button {
line-height: 1.3;
}
/* ============================================================
* Allergenes bouton "i" + modale generale (14 INCO)
* Info reglementaire generale (pas un calcul par produit). La modale reutilise
* le pattern overlay du composer ; z-index 220 pour passer au-dessus de lui.
* ============================================================ */
/* Le bouton "i" se superpose au coin de l'image de la carte. */
.product-card__image-wrap {
position: relative;
}
.allergen-info-btn {
width: 2rem;
height: 2rem;
border-radius: 50%;
border: 2px solid var(--color-brand-dark);
background: var(--color-bg-card);
color: var(--color-brand-dark);
font-weight: var(--font-weight-bold);
font-style: italic;
font-size: var(--font-size-base);
line-height: 1;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
box-shadow: var(--shadow-overlay);
}
.product-card__image-wrap .allergen-info-btn {
position: absolute;
top: var(--space-2);
right: var(--space-2);
z-index: 2;
}
.product-detail__info .allergen-info-btn {
margin-top: var(--space-3);
}
.allergen-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
z-index: 220;
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-4);
animation: composer-fade-in var(--transition-base) both;
}
.allergen-modal {
position: relative;
background: var(--color-bg-card);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-overlay);
width: 100%;
max-width: 640px;
max-height: 90vh;
overflow-y: auto;
padding: var(--space-6);
animation: composer-slide-up var(--transition-base) both;
}
.allergen-modal-close {
position: absolute;
top: var(--space-3);
right: var(--space-3);
width: 2.25rem;
height: 2.25rem;
border-radius: 50%;
border: none;
background: var(--color-bg-page);
color: var(--color-text-primary);
font-size: var(--font-size-md);
cursor: pointer;
}
.allergen-modal-title {
font-size: var(--font-size-xl);
font-weight: var(--font-weight-bold);
color: var(--color-text-primary);
margin-bottom: var(--space-2);
}
.allergen-modal-intro {
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
margin-bottom: var(--space-4);
}
.allergen-modal-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: var(--space-2);
}
.allergen-modal-list li {
padding: var(--space-2) var(--space-3);
background: var(--color-bg-page);
border-radius: var(--radius-md);
}
.allergen-name {
font-weight: var(--font-weight-bold);
color: var(--color-text-primary);
}
.allergen-desc {
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
}
.composer-card__price {
font-size: var(--font-size-sm);
color: var(--color-text-muted);

View file

@ -0,0 +1,132 @@
/*
* allergens.js Modale GENERALE d'information allergenes (front borne).
*
* Information reglementaire (UE INCO 1169/2011) presentee au client : la liste
* des 14 allergenes a declaration obligatoire. C'est une info GENERALE (pas un
* calcul par produit) ; le mapping ingredient_allergen par produit reste differe.
*
* CSP 'self' : aucun script inline, aucun handler inline. Le DOM est construit par
* l'API (createElement/textContent) ; textContent neutralise toute injection.
* Les donnees viennent de data.js (loadAllergens) : liste fixe en P5, /api/allergens
* au swap P4. openAllergenModal prend la liste en parametre pour rester independant
* de la couche de chargement (et testable sans fetch).
*/
const OVERLAY_CLASS = 'allergen-modal-overlay';
/* Reference stable du handler clavier pour pouvoir le retirer a la fermeture. */
function onKeydown(event) {
if (event.key === 'Escape') {
closeAllergenModal();
}
}
/**
* Construit le bouton "i" qui ouvre la modale. `onOpen` est appele au clic ;
* la propagation est stoppee pour ne pas declencher le clic de la carte produit
* (sur la carte, le bouton est superpose a une zone cliquable).
* @param {() => void} onOpen
* @returns {HTMLButtonElement}
*/
export function buildAllergenInfoButton(onOpen) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'allergen-info-btn';
btn.setAttribute('aria-label', 'Informations allergenes');
btn.title = 'Informations allergenes';
btn.textContent = 'i';
btn.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
if (typeof onOpen === 'function') {
onOpen();
}
});
return btn;
}
/**
* Ouvre la modale generale listant les allergenes fournis. Idempotent : une
* eventuelle modale ouverte est d'abord fermee (pas de doublon empile).
* @param {Array<{id:number, name:string, description?:string}>} allergens
* @returns {HTMLElement} l'overlay cree
*/
export function openAllergenModal(allergens) {
closeAllergenModal();
const list = Array.isArray(allergens) ? allergens : [];
const overlay = document.createElement('div');
overlay.className = OVERLAY_CLASS;
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
overlay.setAttribute('aria-label', 'Informations allergenes');
const modal = document.createElement('div');
modal.className = 'allergen-modal';
const closeBtn = document.createElement('button');
closeBtn.type = 'button';
closeBtn.className = 'allergen-modal-close';
closeBtn.setAttribute('aria-label', 'Fermer');
closeBtn.textContent = 'x';
closeBtn.addEventListener('click', closeAllergenModal);
modal.appendChild(closeBtn);
const title = document.createElement('h2');
title.className = 'allergen-modal-title';
title.textContent = 'Allergenes';
modal.appendChild(title);
const intro = document.createElement('p');
intro.className = 'allergen-modal-intro';
intro.textContent = 'Les 14 allergenes a declaration obligatoire (reglement UE INCO 1169/2011). Pour toute question, demandez en caisse.';
modal.appendChild(intro);
const ul = document.createElement('ul');
ul.className = 'allergen-modal-list';
for (const allergen of list) {
const li = document.createElement('li');
const name = document.createElement('span');
name.className = 'allergen-name';
name.textContent = String(allergen.name ?? '');
li.appendChild(name);
if (allergen.description) {
const desc = document.createElement('span');
desc.className = 'allergen-desc';
desc.textContent = ' - ' + String(allergen.description);
li.appendChild(desc);
}
ul.appendChild(li);
}
modal.appendChild(ul);
overlay.appendChild(modal);
// Clic sur le fond (hors du panneau) = fermeture ; clic dans le panneau, non.
overlay.addEventListener('click', (event) => {
if (event.target === overlay) {
closeAllergenModal();
}
});
document.addEventListener('keydown', onKeydown);
document.body.appendChild(overlay);
return overlay;
}
/**
* Ferme la modale si elle est ouverte et retire le handler clavier. Sans effet
* si aucune modale n'est ouverte (sur appel ou Echap repete).
*/
export function closeAllergenModal() {
const existing = document.querySelector('.' + OVERLAY_CLASS);
if (existing && existing.parentNode) {
existing.parentNode.removeChild(existing);
}
document.removeEventListener('keydown', onKeydown);
}

View file

@ -19,6 +19,9 @@
* ----------------------------------------------------------------------- */
const CATEGORIES_URL = 'data/categories.json';
const PRODUCTS_URL = 'data/produits.json';
/* Liste fixe des 14 allergenes INCO (info generale, modale borne). TODO(P4):
* remplacer par '/api/allergens'. Le reste du fichier est API-agnostique. */
const ALLERGENS_URL = 'data/allergens.json';
/** @type {Array|null} — in-memory cache to avoid repeated fetches */
let _categoriesCache = null;
@ -26,6 +29,9 @@ let _categoriesCache = null;
/** @type {Object|null} */
let _productsCache = null;
/** @type {Array|null} */
let _allergensCache = null;
/**
* Fetches and caches the categories list.
* @returns {Promise<Array>}
@ -50,6 +56,18 @@ export async function loadProducts() {
return _productsCache;
}
/**
* Fetches and caches the 14 INCO allergens (general info modal).
* @returns {Promise<Array>}
*/
export async function loadAllergens() {
if (_allergensCache) return _allergensCache;
const res = await fetch(ALLERGENS_URL);
if (!res.ok) throw new Error(`Failed to load allergens: HTTP ${res.status}`);
_allergensCache = await res.json();
return _allergensCache;
}
/**
* Returns the array of products for a given category slug.
* Returns [] if the slug is not found.

View file

@ -0,0 +1,4 @@
{
"//": "Marque les scripts du kiosk comme ESM (import/export) pour Node (tests). Le navigateur les charge via <script type=module> independamment de ce fichier. Scoping local : evite d'imposer type:module a la racine (qui casserait les hooks CommonJS).",
"type": "module"
}

View file

@ -15,10 +15,11 @@
* 3. Redirect to products.html?category=<slug>
*/
import { findProduct } from './data.js';
import { findProduct, loadAllergens } from './data.js';
import { addToCart, formatPrice, escHtml } from './state.js';
import { refreshCartBadge } from './nav.js';
import { openMenuComposer } from './page-product-menu.js';
import { buildAllergenInfoButton, openAllergenModal } from './allergens.js';
const params = new URLSearchParams(window.location.search);
const productId = parseInt(params.get('id'), 10);
@ -78,6 +79,18 @@ async function renderProduct() {
</div>
`;
// Bouton "i" allergenes (modale generale) dans le bloc info de la fiche.
// Echec de chargement non bloquant : la fiche reste fonctionnelle.
try {
const allergens = await loadAllergens();
const info = container.querySelector('.product-detail__info');
if (info) {
info.appendChild(buildAllergenInfoButton(() => openAllergenModal(allergens)));
}
} catch (e) {
console.error('loadAllergens error:', e);
}
document.getElementById('add-to-cart-btn').addEventListener('click', () => {
addToCart({
id: product.id,

View file

@ -6,8 +6,9 @@
* On product card click, navigates to product.html?id=<id>&category=<slug>.
*/
import { getProductsByCategory, getCategoryById, CATEGORY_ID_TO_SLUG } from './data.js';
import { getProductsByCategory, getCategoryById, CATEGORY_ID_TO_SLUG, loadAllergens } from './data.js';
import { formatPrice, escHtml } from './state.js';
import { buildAllergenInfoButton, openAllergenModal } from './allergens.js';
const params = new URLSearchParams(window.location.search);
const categoryId = parseInt(params.get('category'), 10) || 1;
@ -49,6 +50,15 @@ async function renderProducts() {
return;
}
// Liste generale des allergenes (modale "i"). Chargee une fois, partagee par
// toutes les cartes ; un echec ne doit pas casser l'affichage produits.
let allergens = [];
try {
allergens = await loadAllergens();
} catch (e) {
console.error('loadAllergens error:', e);
}
grid.innerHTML = '';
products.forEach(product => {
const card = document.createElement('a');
@ -71,6 +81,12 @@ async function renderProducts() {
<span class="product-card__price">${formatPrice(product.prix)}</span>
</div>
`;
// Bouton "i" allergenes superpose a l'image ; son clic ouvre la modale
// generale et ne declenche pas la navigation de la carte (stopPropagation).
const infoBtn = buildAllergenInfoButton(() => openAllergenModal(allergens));
card.querySelector('.product-card__image-wrap').appendChild(infoBtn);
grid.appendChild(card);
});

View file

@ -0,0 +1,16 @@
[
{ "id": 1, "name": "Cereales contenant du gluten", "description": "Ble, seigle, orge, avoine, epeautre, kamut et produits derives." },
{ "id": 2, "name": "Crustaces", "description": "Et produits a base de crustaces." },
{ "id": 3, "name": "Oeufs", "description": "Et produits a base d'oeufs." },
{ "id": 4, "name": "Poissons", "description": "Et produits a base de poissons." },
{ "id": 5, "name": "Arachides", "description": "Et produits a base d'arachides." },
{ "id": 6, "name": "Soja", "description": "Et produits a base de soja." },
{ "id": 7, "name": "Lait", "description": "Et produits a base de lait (y compris le lactose)." },
{ "id": 8, "name": "Fruits a coque", "description": "Amandes, noisettes, noix, noix de cajou, pistaches et autres." },
{ "id": 9, "name": "Celeri", "description": "Et produits a base de celeri." },
{ "id": 10, "name": "Moutarde", "description": "Et produits a base de moutarde." },
{ "id": 11, "name": "Graines de sesame", "description": "Et produits a base de graines de sesame." },
{ "id": 12, "name": "Anhydride sulfureux et sulfites", "description": "En concentration de plus de 10 mg/kg ou 10 mg/l." },
{ "id": 13, "name": "Lupin", "description": "Et produits a base de lupin." },
{ "id": 14, "name": "Mollusques", "description": "Et produits a base de mollusques." }
]

110
tests/js/allergens.test.js Normal file
View file

@ -0,0 +1,110 @@
/*
* Tests du module allergens du front borne (node:test + jsdom).
*
* Couvre le contrat de PR-C : la liste fixe des 14 allergenes INCO (data borne,
* se branchera sur /api/allergens au swap P4), la construction du bouton "i", et
* la modale GENERALE (ouverture, listing des 14, fermeture par bouton/overlay/
* Escape, idempotence). DOM simule par jsdom : aucun navigateur requis.
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { JSDOM } from 'jsdom';
import {
buildAllergenInfoButton,
openAllergenModal,
closeAllergenModal,
} from '../../src/public/borne/assets/js/allergens.js';
const here = dirname(fileURLToPath(import.meta.url));
const allergensJsonPath = join(here, '../../src/public/borne/data/allergens.json');
function setupDom() {
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>');
global.window = dom.window;
global.document = dom.window.document;
return dom;
}
function loadAllergensFixture() {
return JSON.parse(readFileSync(allergensJsonPath, 'utf8'));
}
test('data/allergens.json liste exactement les 14 allergenes INCO', () => {
const list = loadAllergensFixture();
assert.ok(Array.isArray(list));
assert.equal(list.length, 14);
for (const a of list) {
assert.equal(typeof a.id, 'number');
assert.equal(typeof a.name, 'string');
assert.ok(a.name.trim().length > 0);
}
const names = list.map((a) => a.name);
assert.equal(new Set(names).size, 14, 'noms uniques');
// Quelques jalons de la liste reglementaire (UE INCO 1169/2011 annexe II).
const joined = names.join(' | ').toLowerCase();
for (const expected of ['gluten', 'lait', 'arachide', 'soja', 'mollusque']) {
assert.ok(joined.includes(expected), `attendu: ${expected}`);
}
});
test('buildAllergenInfoButton cree un bouton "i" qui declenche onOpen', () => {
setupDom();
let opened = 0;
const btn = buildAllergenInfoButton(() => { opened += 1; });
assert.equal(btn.tagName, 'BUTTON');
assert.equal(btn.type, 'button');
assert.ok(btn.className.includes('allergen-info-btn'));
assert.ok(btn.getAttribute('aria-label'));
btn.click();
assert.equal(opened, 1, 'le clic ouvre la modale');
});
test('openAllergenModal affiche une modale listant les 14 allergenes', () => {
setupDom();
const list = loadAllergensFixture();
const overlay = openAllergenModal(list);
assert.ok(document.body.contains(overlay));
assert.equal(overlay.getAttribute('role'), 'dialog');
assert.equal(overlay.getAttribute('aria-modal'), 'true');
const items = overlay.querySelectorAll('.allergen-modal-list li');
assert.equal(items.length, 14);
assert.ok(overlay.textContent.toLowerCase().includes('lait'));
});
test('la modale se ferme via le bouton de fermeture', () => {
setupDom();
openAllergenModal(loadAllergensFixture());
document.querySelector('.allergen-modal-close').click();
assert.equal(document.querySelector('.allergen-modal-overlay'), null);
});
test('la modale se ferme par clic sur l overlay (hors contenu)', () => {
const dom = setupDom();
const overlay = openAllergenModal(loadAllergensFixture());
overlay.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true }));
assert.equal(document.querySelector('.allergen-modal-overlay'), null);
});
test('la modale se ferme avec la touche Echap', () => {
const dom = setupDom();
openAllergenModal(loadAllergensFixture());
document.dispatchEvent(new dom.window.KeyboardEvent('keydown', { key: 'Escape' }));
assert.equal(document.querySelector('.allergen-modal-overlay'), null);
});
test('ouvrir deux fois ne duplique pas la modale (idempotent)', () => {
setupDom();
const list = loadAllergensFixture();
openAllergenModal(list);
openAllergenModal(list);
assert.equal(document.querySelectorAll('.allergen-modal-overlay').length, 1);
closeAllergenModal();
assert.equal(document.querySelector('.allergen-modal-overlay'), null);
});

4
tests/js/package.json Normal file
View file

@ -0,0 +1,4 @@
{
"//": "Les tests front borne sont en ESM (import). Scoping local pour ne pas imposer type:module a la racine.",
"type": "module"
}