chore(borne): bascule allergenes sur /api/allergens + menage donnees/docs (#103)
All checks were successful
CI / secret-scan (push) Successful in 18s
CI / php-lint (push) Successful in 36s
CI / static-tests (push) Successful in 1m25s
CI / js-tests (push) Successful in 37s

This commit is contained in:
Corentin JOGUET 2026-06-24 12:37:54 +02:00
parent 3c53908952
commit 6f2aedc699
16 changed files with 185 additions and 242 deletions

View file

@ -51,10 +51,9 @@ Code de reference : routes dans `src/public/admin/index.php`, controleurs dans
| Famille | Prefixe | Rendu | Authentification | Exemple | | Famille | Prefixe | Rendu | Authentification | Exemple |
|---|---|---|---|---| |---|---|---|---|---|
| Pages back-office | aucun | HTML (vue serveur + `layout.php`) | session admin | `/login`, `/forgot_password` | | Pages back-office | aucun | HTML (vue serveur + `layout.php`) | session admin | `/login`, `/forgot_password` |
| API REST | `/api/` | JSON (enveloppe section 7) | selon la ressource (section 10) | `/api/health`, `/api/categories` (prevu) | | API REST | `/api/` | JSON (enveloppe section 7) | selon la ressource (section 10) | `/api/health`, `/api/categories` (livre) |
La borne (kiosk) consommera l'API REST `/api/*` (P4). En attendant, elle lit un repli JSON La borne (kiosk) consomme l'API REST `/api/*` en lecture pour le catalogue (voir section 8.3).
statique sous `src/public/borne/data/` (voir section 8.3).
--- ---
@ -107,7 +106,7 @@ is_active) et d'`Authorizer` (RG-T03, permissions rechargees depuis la base). Re
si la session est absente, expiree ou le compte desactive. Les autorisations par operation si la session est absente, expiree ou le compte desactive. Les autorisations par operation
(et le PIN des actions sensibles, RG-T13) se cablent quand les operations existent (P3). (et le PIN des actions sensibles, RG-T13) se cablent quand les operations existent (P3).
### 5.2 API kiosk - lecture catalogue + commande (prevu P4, public) ### 5.2 API kiosk - lecture catalogue + commande (livre, public)
La borne est publique (aucune session) ; cf. `mlt.md` CREATE_ORDER, declencheur kiosk. La borne est publique (aucune session) ; cf. `mlt.md` CREATE_ORDER, declencheur kiosk.
@ -270,18 +269,16 @@ Codes specifiques nommes par le MLT, en surcharge du socle : `CANNOT_CANCEL_IN_S
`INVALID_TRANSITION` (409) pour l'annulation (`mlt.md` 7.1, `security-sequence.md`). Meme format `INVALID_TRANSITION` (409) pour l'annulation (`mlt.md` 7.1, `security-sequence.md`). Meme format
d'enveloppe. d'enveloppe.
### 8.3 Divergence connue : repli JSON de la borne ### 8.3 Nommage borne vs canonique : le rapprochement dans data.js
Le repli statique de la borne (`src/public/borne/data/categories.json`, `produits.json`) provient Le front de la borne attend un nommage historique heterogene issu des sources de l'ecole
des sources de l'ecole et porte un nommage different et heterogene (`title`/`nom`, `prix`, `image`, (`title`/`nom`, `prix`, `image`, `type`). L'API sert la forme canonique de 8.1
`type`). Ce contrat est fige par le brief ecole et consomme tel quel par le JS de la borne via (`/api/categories`, `/api/products`, `/api/menus`, `/api/allergens`). Le rapprochement se fait
`data.js`. en un point unique : la couche `data.js`, qui deballe l'enveloppe `{ data }` et mappe la forme
canonique vers ce que la borne attend. Les anciens fichiers JSON statiques sous
`src/public/borne/data/` ont ete retires.
La convention canonique reste celle de 8.1. Le rapprochement se fait en un point unique : la couche | Forme borne | Canonique API / dictionnaire |
`data.js` (bascule prevue en P4). Quand l'API exposera `/api/categories` et `/api/products`, elle
servira la forme canonique ; `data.js` mappera vers ce que la borne attend.
| Repli borne | Canonique API / dictionnaire |
|---|---| |---|---|
| `title` (categorie) | `name` | | `title` (categorie) | `name` |
| `nom` (produit) | `name` | | `nom` (produit) | `name` |

View file

@ -23,9 +23,12 @@ Accueil
-> Remerciement -> Remerciement
``` ```
Le kiosk construit, lui, eclate cet ecran unique en **pages distinctes** et n'a Le kiosk construit a desormais rejoint ce paradigme : l'ecran de commande
pas de panneau de commande persistant. C'est l'origine du sentiment "ca ne (`products.html`) porte un **panneau de commande persistant** a droite, les options
correspond pas". produit et le composeur de menu s'ouvrent **en modale** par-dessus la grille, et le
**chevalet** (saisie du numero de table) s'ouvre en modale au paiement sur place. Les
pages intermediaires `product.html` et `cart.html` du premier jet ont ete retirees.
Cette note garde la trace de la decomposition maquette -> code et des ecarts resorbes.
## 2. Decomposition ecran par ecran ## 2. Decomposition ecran par ecran
@ -87,25 +90,29 @@ correspond pas".
| Maquette | Kiosk construit | Verdict | | Maquette | Kiosk construit | Verdict |
|----------|-----------------|---------| |----------|-----------------|---------|
| 1. Accueil sur place / a emporter | `index.html` | conforme | | 1. Accueil sur place / a emporter | `index.html` | conforme |
| 2 + 6. Ecran de commande unique (bandeau + grille + **panneau persistant**) | eclate en `categories.html` -> `products.html` -> `cart.html` | divergence structurante : multi-pages, et **pas de panneau de commande persistant** | | 2 + 6. Ecran de commande unique (bandeau + grille + **panneau persistant**) | `products.html` : bandeau categories (`category-strip.js`) + grille + **panneau de commande persistant** a droite (`order-panel.js`) | conforme |
| (pas de page categories separee) | `categories.html` plein ecran "Que souhaitez-vous commander ?" | ecran **ajoute** (la maquette met les categories en bandeau) | | (pas de page categories separee) | `categories.html` plein ecran "Que souhaitez-vous commander ?" | ecran **ajoute** (la maquette met les categories en bandeau) |
| 3-5. Composeur menu = **assistant modal en etapes** | `page-product-menu.js` = composition **libre** | divergence (le refactor "consommer les slots /api/menus" est deja en file P4) | | 3-5. Composeur menu = **assistant modal en etapes** | `page-product-menu.js` : composeur **modal pilote par les slots** de `/api/menus/{id}` (format Maxi puis 1 etape par slot) | conforme |
| 8. Modale d'option produit (taille + quantite) | `product.html` (page) | divergence : page au lieu de modale | | 8. Modale d'option produit (taille + quantite) | `product-options.js` : **modale** d'options (taille R4 + stepper de quantite) au-dessus de la grille | conforme |
| 9. Ecran **chevalet** dedie (saisie numero) | numero gere par l'API (chunk 1a), affiche en confirmation | manquant cote ecran | | 9. Ecran **chevalet** dedie (saisie numero) | **modale chevalet** au paiement sur place (`page-payment.js`), numero pose via l'API ; rappele en confirmation | conforme |
| (aucun ecran de paiement) | `payment.html` "Carte bancaire / Especes" | ecran **ajoute** par le build | | (aucun ecran de paiement) | `payment.html` "Carte bancaire / Especes" | ecran **ajoute** par le build |
| 10. Remerciement | `confirmation.html` | conforme | | 10. Remerciement | `confirmation.html` | conforme |
## 4. Ecarts structurants (le fond du sujet) ## 4. Ecarts structurants (resorbes)
1. **Paradigme inverse.** Maquette = **mono-ecran** (un plan de commande avec Les ecarts structurants du premier jet ont ete realignes sur la maquette :
categories en bandeau et un panneau recapitulatif persistant a droite, modales
par-dessus). Build = **multi-pages** classiques (categories -> produits -> 1. **Paradigme.** L'ecran de commande (`products.html`) suit le plan mono-ecran de
produit -> panier). C'est l'ecart structurant principal. la maquette : categories en bandeau (`category-strip.js`), grille produits, et
2. **Panneau de commande lateral absent.** La piece centrale de la maquette panneau recapitulatif persistant a droite ; les options et le composeur de menu
(numero de commande, lignes editables avec corbeille, TOTAL ttc, Abandon / s'ouvrent en modale par-dessus. Les pages `product.html` et `cart.html` du
Payer, visible en permanence) n'est pas presente dans le build. premier jet ont ete retirees.
3. **Composition de menu.** Maquette = assistant modal en etapes ; build = 2. **Panneau de commande lateral.** La piece centrale de la maquette (numero de
composition libre cote client (`page-product-menu.js`). commande, lignes editables avec quantite et retrait, TOTAL ttc, Abandon / Payer)
est rendue par `order-panel.js`, visible en permanence sur l'ecran de commande.
3. **Composition de menu.** Le composeur (`page-product-menu.js`) est un assistant
modal en etapes pilote par les slots de `/api/menus/{id}` (format Maxi puis une
etape par slot), conforme a l'enchainement de la maquette.
## 5. Rebrand McDonald's -> Wakdo ## 5. Rebrand McDonald's -> Wakdo
@ -116,6 +123,8 @@ note n'est donc pas le rebrand mais la **structure** des ecrans.
## 6. Suite ## 6. Suite
Re-alignement du kiosk sur la maquette (panneau persistant + bandeau categories + Le re-alignement du kiosk sur la maquette (panneau persistant + bandeau categories
composeur en modale) = chantier UI conduit via un cycle FD dedie. Backlog des + composeur en modale + chevalet en modale) est livre. La borne lit le catalogue
divergences = section 3 ci-dessus. via l'API REST (`/api/categories|products|menus|allergens`). Reste a faire : la
generation dynamique de l'ecran categories depuis `GET /api/categories` (section 3,
ecran categories) et le polissage visuel du rebrand Wakdo.

View file

@ -9,8 +9,9 @@ use App\Core\DatabaseInterface;
/** /**
* Lecture des allergenes a declaration obligatoire (INCO) : info GENERALE (les 14 * Lecture des allergenes a declaration obligatoire (INCO) : info GENERALE (les 14
* categories), pas un calcul par produit (le mapping ingredient_allergen reste * categories), pas un calcul par produit (le mapping ingredient_allergen reste
* differe). Sert l'endpoint public anonyme /api/allergens. Le schema ne porte que * differe). Sert l'endpoint public anonyme /api/allergens. Le schema porte
* code + name ; les descriptions riches restent cote borne (data/allergens.json). * code + name + description ; la description (texte INCO seede) est exposee par
* l'API et consommee par la borne via /api/allergens.
* *
* Non `final` : seam de test (sous-classe -> double sans base). * Non `final` : seam de test (sous-classe -> double sans base).
*/ */
@ -27,6 +28,6 @@ class AllergenRepository
*/ */
public function all(): array public function all(): array
{ {
return $this->db->fetchAll('SELECT id, code, name FROM allergen ORDER BY id'); return $this->db->fetchAll('SELECT id, code, name, description FROM allergen ORDER BY id');
} }
} }

View file

@ -187,7 +187,7 @@ class CatalogueController extends Controller
/** /**
* @param array<string, mixed> $row * @param array<string, mixed> $row
* @return array{id: int, code: string, name: string} * @return array{id: int, code: string, name: string, description: ?string}
*/ */
private function presentAllergen(array $row): array private function presentAllergen(array $row): array
{ {
@ -195,6 +195,7 @@ class CatalogueController extends Controller
'id' => (int) ($row['id'] ?? 0), 'id' => (int) ($row['id'] ?? 0),
'code' => (string) ($row['code'] ?? ''), 'code' => (string) ($row['code'] ?? ''),
'name' => (string) ($row['name'] ?? ''), 'name' => (string) ($row['name'] ?? ''),
'description' => $this->nullableString($row['description'] ?? null),
]; ];
} }

View file

@ -7,9 +7,9 @@
* *
* CSP 'self' : aucun script inline, aucun handler inline. Le DOM est construit par * CSP 'self' : aucun script inline, aucun handler inline. Le DOM est construit par
* l'API (createElement/textContent) ; textContent neutralise toute injection. * l'API (createElement/textContent) ; textContent neutralise toute injection.
* Les donnees viennent de data.js (loadAllergens) : liste fixe en P5, /api/allergens * Les donnees viennent de data.js (loadAllergens), qui lit /api/allergens.
* au swap P4. openAllergenModal prend la liste en parametre pour rester independant * openAllergenModal prend la liste en parametre pour rester independant de la
* de la couche de chargement (et testable sans fetch). * couche de chargement (et testable sans fetch).
*/ */
const OVERLAY_CLASS = 'allergen-modal-overlay'; const OVERLAY_CLASS = 'allergen-modal-overlay';

View file

@ -10,18 +10,17 @@
* indexe par slug de categorie ; menus glisses sous la cle 'menus'). Les signatures * indexe par slug de categorie ; menus glisses sous la cle 'menus'). Les signatures
* publiques et les formes de retour sont inchangees -> les pages n'ont pas bouge. * publiques et les formes de retour sont inchangees -> les pages n'ont pas bouge.
* *
* Les allergenes restent un repli statique (data/allergens.json) : leur bascule * Les allergenes sont desormais lus depuis /api/allergens (id/code/name/description),
* sur /api/allergens est un chunk ulterieur. * comme les autres collections catalogue : le repli statique a ete retire.
*/ */
const CATEGORIES_URL = '/api/categories'; const CATEGORIES_URL = '/api/categories';
const PRODUCTS_URL = '/api/products'; const PRODUCTS_URL = '/api/products';
const MENUS_URL = '/api/menus'; const MENUS_URL = '/api/menus';
/* Liste fixe des 14 allergenes INCO (info generale, modale borne). L'endpoint /* Les 14 allergenes INCO (info generale, modale borne). L'endpoint /api/allergens
* /api/allergens existe desormais (id/code/name), mais la borne garde ce JSON * porte id/code/name/description (la description INCO est seede en base) -> la borne
* statique : il porte les DESCRIPTIONS riches, absentes du schema allergen. Bascule * la consomme via l'API, comme les autres collections catalogue. */
* possible si les descriptions sont ajoutees cote API. */ const ALLERGENS_URL = '/api/allergens';
const ALLERGENS_URL = 'data/allergens.json';
/* Memoisation par PROMESSE (pas par resultat) : N appelants concurrents au meme /* Memoisation par PROMESSE (pas par resultat) : N appelants concurrents au meme
* chargement partagent UNE seule requete reseau (evite les fetch /api/* redondants * chargement partagent UNE seule requete reseau (evite les fetch /api/* redondants
@ -148,17 +147,16 @@ export async function loadMenu(id) {
} }
/** /**
* Fetches and caches the 14 INCO allergens (general info modal). Repli statique : * Fetches and caches the 14 INCO allergens (general info modal). Consomme
* la reponse est un tableau nu (pas d'enveloppe), conserve tel quel. * /api/allergens (enveloppe { data }, forme canonique id/code/name/description) et
* @returns {Promise<Array>} * ramene chaque entree a la forme borne { id, name, description } attendue par la
* modale (allergens.js) ; le champ `code` n'est pas utilise cote borne.
* @returns {Promise<Array<{id:number, name:string, description:?string}>>}
*/ */
export function loadAllergens() { export function loadAllergens() {
if (!_allergensPromise) { if (!_allergensPromise) {
_allergensPromise = fetch(ALLERGENS_URL) _allergensPromise = fetchCollection(ALLERGENS_URL)
.then(res => { .then(rows => rows.map(a => ({ id: a.id, name: a.name, description: a.description ?? null })))
if (!res.ok) throw new Error(`Failed to load allergens: HTTP ${res.status}`);
return res.json();
})
.catch(e => { _allergensPromise = null; throw e; }); .catch(e => { _allergensPromise = null; throw e; });
} }
return _allergensPromise; return _allergensPromise;

View file

@ -13,11 +13,11 @@
<body class="categories-page"> <body class="categories-page">
<!-- <!--
Categories screen. Categories screen — static scaffold (9 categories) listed in catalogue order.
Data source: docs/merise/_sources/categories.json (9 categories).
Image paths: assets/images/categories/{title}.png — verified against filesystem. Image paths: assets/images/categories/{title}.png — verified against filesystem.
In P4 this page will be generated dynamically from GET /api/categories. The cards link to products.html?category=<id>; the product/menu/allergen data
For now it is a static scaffold that matches the data contract exactly. is fetched from the REST API by data.js. Generating this list from
GET /api/categories is a later UI alignment step.
--> -->
<header class="site-header"> <header class="site-header">
@ -41,10 +41,9 @@
<p class="categories-main__sub">Choisissez une categorie pour decouvrir nos produits</p> <p class="categories-main__sub">Choisissez une categorie pour decouvrir nos produits</p>
<!-- <!--
9 categories from categories.json, in the same order as the source. 9 categories in catalogue order. Each card links to a product page
Each card links to a product page (products.html?category=<id>) — stub URL (products.html?category=<id>). The link is functional HTML; no JS needed.
for future P5 implementation. The link is functional HTML; no JS needed. The category title is used as alt text and visible label.
title field from JSON used as alt text and visible label.
--> -->
<nav class="category-grid" aria-label="Navigation par categorie"> <nav class="category-grid" aria-label="Navigation par categorie">

View file

@ -1,22 +1,10 @@
# Donnees statiques de la borne (repli P5) # Donnees de la borne
`categories.json` et `produits.json` sont un **repli statique fige** consomme par La borne consomme l'API REST en lecture : `/api/categories`, `/api/products`,
le front de la borne (Bloc 1 / P5) tant que l'API REST n'existe pas. Ils sont `/api/menus` et `/api/allergens` (cf. `docs/api/conventions.md` section 5.2). La
copies du jeu de donnees source de l'ecole (`docs/merise/_sources/`), **pas** couche `assets/js/data.js` deballe l'enveloppe `{ data }` et traduit la forme
generes depuis la base. canonique vers la forme attendue par les pages.
## Ces fichiers ne refletent pas la base Les anciens fichiers JSON statiques (`categories.json`, `produits.json`,
`allergens.json`) qui servaient de repli avant l'API ont ete retires : la borne
Le catalogue servi ici est le jeu source complet (66 produits) ; le seed de la reflete la base via l'API.
base (`db/seeds/0002_catalogue.sql`) en est un sous-ensemble curate (53 produits).
Les categories, elles, coincident (9 de chaque cote). La borne est une demo front
sur donnees statiques : un ecart de comptage produits avec la table `product` est
**attendu**, ce n'est pas une incoherence a corriger.
## Point de bascule (P4)
`assets/js/data.js` lit ces fichiers via les constantes `CATEGORIES_URL` /
`PRODUCTS_URL`. En P4, ces constantes pointeront vers `/api/categories` et
`/api/products` (memes formes de retour, le reste du code est agnostique). La
borne refletera alors la base via l'API, et ces fichiers deviendront obsoletes
(a retirer a ce moment-la).

View file

@ -1,16 +0,0 @@
[
{ "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." }
]

View file

@ -1,11 +0,0 @@
[
{ "id": 1, "title": "menus", "slug": "menus", "image": "assets/images/categories/menus.png" },
{ "id": 2, "title": "boissons", "slug": "boissons", "image": "assets/images/categories/boissons.png" },
{ "id": 3, "title": "burgers", "slug": "burgers", "image": "assets/images/categories/burgers.png" },
{ "id": 4, "title": "frites", "slug": "frites", "image": "assets/images/categories/frites.png" },
{ "id": 5, "title": "encas", "slug": "encas", "image": "assets/images/categories/encas.png" },
{ "id": 6, "title": "wraps", "slug": "wraps", "image": "assets/images/categories/wraps.png" },
{ "id": 7, "title": "salades", "slug": "salades", "image": "assets/images/categories/salades.png" },
{ "id": 8, "title": "desserts", "slug": "desserts", "image": "assets/images/categories/desserts.png" },
{ "id": 9, "title": "sauces", "slug": "sauces", "image": "assets/images/categories/sauces.png" }
]

View file

@ -1,86 +0,0 @@
{
"menus": [
{ "id": 1, "nom": "Menu Le 280", "prix": 880, "image": "assets/images/produits/burgers/280.png", "type": "menu" },
{ "id": 2, "nom": "Menu Big Tasty", "prix": 1060, "image": "assets/images/produits/burgers/big-tasty-1-viande.png", "type": "menu" },
{ "id": 3, "nom": "Menu Big Tasty Bacon", "prix": 1090, "image": "assets/images/produits/burgers/big-tasty-bacon-1-viande.png", "type": "menu" },
{ "id": 4, "nom": "Menu Big Mac", "prix": 800, "image": "assets/images/produits/burgers/bigmac.png", "type": "menu" },
{ "id": 5, "nom": "Menu CBO", "prix": 1090, "image": "assets/images/produits/burgers/cbo.png", "type": "menu" },
{ "id": 6, "nom": "Menu MC Chicken", "prix": 930, "image": "assets/images/produits/burgers/mcchicken.png", "type": "menu" },
{ "id": 7, "nom": "Menu MC Crispy", "prix": 720, "image": "assets/images/produits/burgers/mccrispy.png", "type": "menu" },
{ "id": 8, "nom": "Menu MC Fish", "prix": 720, "image": "assets/images/produits/burgers/mcfish.png", "type": "menu" },
{ "id": 9, "nom": "Menu Royal Bacon", "prix": 705, "image": "assets/images/produits/burgers/royalbacon.png", "type": "menu" },
{ "id": 10, "nom": "Menu Royal Cheese", "prix": 640, "image": "assets/images/produits/burgers/royalcheese.png", "type": "menu" },
{ "id": 11, "nom": "Menu Royal Deluxe", "prix": 740, "image": "assets/images/produits/burgers/royaldeluxe.png", "type": "menu" },
{ "id": 12, "nom": "Menu Signature BBQ Beef 2 viandes","prix": 1350,"image": "assets/images/produits/burgers/signature-bbq-beef-2-viandes.png", "type": "menu" },
{ "id": 13, "nom": "Menu Signature Beef BBQ", "prix": 1190, "image": "assets/images/produits/burgers/signature-beef-bbq-burger-1-viande.png", "type": "menu" }
],
"burgers": [
{ "id": 14, "nom": "Le 280", "prix": 680, "image": "assets/images/produits/burgers/280.png", "type": "produit" },
{ "id": 15, "nom": "Big Tasty", "prix": 860, "image": "assets/images/produits/burgers/big-tasty-1-viande.png", "type": "produit" },
{ "id": 16, "nom": "Big Tasty Bacon", "prix": 890, "image": "assets/images/produits/burgers/big-tasty-bacon-1-viande.png", "type": "produit" },
{ "id": 17, "nom": "Big Mac", "prix": 600, "image": "assets/images/produits/burgers/bigmac.png", "type": "produit" },
{ "id": 18, "nom": "CBO", "prix": 890, "image": "assets/images/produits/burgers/cbo.png", "type": "produit" },
{ "id": 19, "nom": "MC Chicken", "prix": 730, "image": "assets/images/produits/burgers/mcchicken.png", "type": "produit" },
{ "id": 20, "nom": "MC Crispy", "prix": 530, "image": "assets/images/produits/burgers/mccrispy.png", "type": "produit" },
{ "id": 21, "nom": "MC Fish", "prix": 485, "image": "assets/images/produits/burgers/mcfish.png", "type": "produit" },
{ "id": 22, "nom": "Royal Bacon", "prix": 510, "image": "assets/images/produits/burgers/royalbacon.png", "type": "produit" },
{ "id": 23, "nom": "Royal Cheese", "prix": 440, "image": "assets/images/produits/burgers/royalcheese.png", "type": "produit" },
{ "id": 24, "nom": "Royal Deluxe", "prix": 540, "image": "assets/images/produits/burgers/royaldeluxe.png", "type": "produit" },
{ "id": 25, "nom": "Signature BBQ Beef 2 viandes","prix": 1140,"image": "assets/images/produits/burgers/signature-bbq-beef-2-viandes.png","type": "produit" },
{ "id": 26, "nom": "Signature Beef BBQ", "prix": 1030, "image": "assets/images/produits/burgers/signature-beef-bbq-burger-1-viande.png","type": "produit" }
],
"boissons": [
{ "id": 27, "nom": "Coca Cola", "prix": 190, "image": "assets/images/produits/boissons/coca-cola.png", "type": "produit" },
{ "id": 28, "nom": "Coca Sans Sucres", "prix": 190, "image": "assets/images/produits/boissons/coca-sans-sucres.png", "type": "produit" },
{ "id": 29, "nom": "Eau", "prix": 100, "image": "assets/images/produits/boissons/eau.png", "type": "produit" },
{ "id": 30, "nom": "Fanta Orange", "prix": 190, "image": "assets/images/produits/boissons/fanta.png", "type": "produit" },
{ "id": 31, "nom": "Ice Tea Peche", "prix": 190, "image": "assets/images/produits/boissons/ice-tea-peche.png", "type": "produit" },
{ "id": 32, "nom": "Ice Tea Citron", "prix": 190, "image": "assets/images/produits/boissons/the-vert-citron-sans-sucres.png", "type": "produit" },
{ "id": 33, "nom": "Jus d'Orange", "prix": 210, "image": "assets/images/produits/boissons/jus-orange.png", "type": "produit" },
{ "id": 34, "nom": "Jus de Pommes Bio", "prix": 230, "image": "assets/images/produits/boissons/jus-pomme-bio.png", "type": "produit" }
],
"frites": [
{ "id": 35, "nom": "Petite Frite", "prix": 145, "image": "assets/images/produits/frites/petite-frite.png", "type": "produit" },
{ "id": 36, "nom": "Moyenne Frite", "prix": 275, "image": "assets/images/produits/frites/moyenne-frite.png", "type": "produit" },
{ "id": 37, "nom": "Grande Frite", "prix": 350, "image": "assets/images/produits/frites/grande-frite.png", "type": "produit" },
{ "id": 38, "nom": "Potatoes", "prix": 215, "image": "assets/images/produits/frites/potatoes.png", "type": "produit" },
{ "id": 39, "nom": "Grande Potatoes", "prix": 340, "image": "assets/images/produits/frites/grande-potatoes.png", "type": "produit" }
],
"encas": [
{ "id": 40, "nom": "Cheeseburger", "prix": 260, "image": "assets/images/produits/encas/cheeseburger.png", "type": "produit" },
{ "id": 41, "nom": "Croc MCdo", "prix": 320, "image": "assets/images/produits/encas/croc-mc-do.png", "type": "produit" },
{ "id": 42, "nom": "Nuggets x4", "prix": 420, "image": "assets/images/produits/encas/nuggets-4.png", "type": "produit" },
{ "id": 43, "nom": "Nuggets x20", "prix": 1300, "image": "assets/images/produits/encas/nuggets-20.png", "type": "produit" }
],
"desserts": [
{ "id": 44, "nom": "Brownie", "prix": 260, "image": "assets/images/produits/desserts/brownies.png", "type": "produit" },
{ "id": 45, "nom": "Cheesecake Chocolat M&M's","prix": 310, "image": "assets/images/produits/desserts/cheesecake-choconuts-m&m-s.png", "type": "produit" },
{ "id": 46, "nom": "Cheesecake Fraise", "prix": 310, "image": "assets/images/produits/desserts/cheesecake-fraise.png", "type": "produit" },
{ "id": 47, "nom": "Cookie", "prix": 320, "image": "assets/images/produits/desserts/cookie.png", "type": "produit" },
{ "id": 48, "nom": "Donut", "prix": 260, "image": "assets/images/produits/desserts/doghnut.png", "type": "produit" },
{ "id": 49, "nom": "Macarons", "prix": 270, "image": "assets/images/produits/desserts/macarons.png", "type": "produit" },
{ "id": 50, "nom": "MC Fleury", "prix": 440, "image": "assets/images/produits/desserts/mcfleury.png", "type": "produit" },
{ "id": 51, "nom": "Muffin", "prix": 360, "image": "assets/images/produits/desserts/muffin.png", "type": "produit" },
{ "id": 52, "nom": "Sunday", "prix": 100, "image": "assets/images/produits/desserts/sunday.png", "type": "produit" }
],
"sauces": [
{ "id": 53, "nom": "Classic Barbecue", "prix": 70, "image": "assets/images/produits/sauces/classic-barbecue.png", "type": "produit" },
{ "id": 54, "nom": "Classic Moutarde", "prix": 70, "image": "assets/images/produits/sauces/classic-moutarde.png", "type": "produit" },
{ "id": 55, "nom": "Creamy Deluxe", "prix": 70, "image": "assets/images/produits/sauces/cremy-deluxe.png", "type": "produit" },
{ "id": 56, "nom": "Ketchup", "prix": 70, "image": "assets/images/produits/sauces/ketchup.png", "type": "produit" },
{ "id": 57, "nom": "Chinoise", "prix": 70, "image": "assets/images/produits/sauces/sauce-chinoise.png", "type": "produit" },
{ "id": 58, "nom": "Curry", "prix": 70, "image": "assets/images/produits/sauces/sauce-curry.png", "type": "produit" },
{ "id": 59, "nom": "Pommes Frites", "prix": 70, "image": "assets/images/produits/sauces/sauce-pommes-frite.png", "type": "produit" }
],
"salades": [
{ "id": 60, "nom": "Petite Salade", "prix": 330, "image": "assets/images/produits/salades/petite-salade.png", "type": "produit" },
{ "id": 61, "nom": "Cesar Classic", "prix": 880, "image": "assets/images/produits/salades/salade-classic-caesar.png","type": "produit" },
{ "id": 62, "nom": "Italienne Mozza", "prix": 880, "image": "assets/images/produits/salades/salade-italian-mozza.png", "type": "produit" }
],
"wraps": [
{ "id": 63, "nom": "MC Wrap Chevre", "prix": 310, "image": "assets/images/produits/wraps/mcwrap-chevre.png", "type": "produit" },
{ "id": 64, "nom": "MC Wrap Poulet Bacon", "prix": 330, "image": "assets/images/produits/wraps/mcwrap-poulet-bacon.png","type": "produit" },
{ "id": 65, "nom": "Ptit Wrap Chevre", "prix": 260, "image": "assets/images/produits/wraps/ptit-wrap-chevre.png", "type": "produit" },
{ "id": 66, "nom": "Ptit Wrap Ranch", "prix": 260, "image": "assets/images/produits/wraps/ptit-wrap-ranch.png", "type": "produit" }
]
}

View file

@ -15,8 +15,8 @@
<!-- <!--
products.html — List of products in a category. products.html — List of products in a category.
Category is determined at runtime from ?category=<id>. Category is determined at runtime from ?category=<id>.
JS (page-products.js) fetches data/produits.json and renders cards. JS (page-products.js) reads the catalogue via data.js, which fetches
In P4: swap fetch URL in data.js to point to GET /api/products?category=<slug>. GET /api/products (and /api/categories, /api/menus), then renders cards.
--> -->
<header class="site-header"> <header class="site-header">

View file

@ -13,7 +13,8 @@ use App\Core\Database;
/** /**
* AllergenRepository contre une vraie MariaDB (schema migre + seed reference). * AllergenRepository contre une vraie MariaDB (schema migre + seed reference).
* Auto-skip si WAKDO_DB_TESTS != 1. Lecture seule (donnees de reference) : aucun * Auto-skip si WAKDO_DB_TESTS != 1. Lecture seule (donnees de reference) : aucun
* fixture/teardown. Verifie que les 14 allergenes INCO sont references avec code+name. * fixture/teardown. Verifie que les 14 allergenes INCO sont references avec
* code + name + description.
*/ */
final class AllergenReadDbTest extends TestCase final class AllergenReadDbTest extends TestCase
{ {
@ -34,7 +35,7 @@ final class AllergenReadDbTest extends TestCase
} }
} }
public function testListsIncoReferenceWithCodeAndName(): void public function testListsIncoReferenceWithCodeNameAndDescription(): void
{ {
$rows = (new AllergenRepository($this->db))->all(); $rows = (new AllergenRepository($this->db))->all();
@ -42,7 +43,11 @@ final class AllergenReadDbTest extends TestCase
foreach ($rows as $a) { foreach ($rows as $a) {
self::assertArrayHasKey('code', $a); self::assertArrayHasKey('code', $a);
self::assertArrayHasKey('name', $a); self::assertArrayHasKey('name', $a);
self::assertArrayHasKey('description', $a);
self::assertNotSame('', (string) ($a['name'] ?? '')); self::assertNotSame('', (string) ($a['name'] ?? ''));
} }
// La description INCO est seede (migration 0001 + seed 0001) : au moins une non vide.
$descriptions = array_filter($rows, static fn (array $a): bool => (string) ($a['description'] ?? '') !== '');
self::assertNotEmpty($descriptions, 'la description INCO doit etre exposee');
} }
} }

View file

@ -87,6 +87,14 @@ final class FakeCatalogueDatabase implements DatabaseInterface
*/ */
public array $autoUnavailableRows = []; public array $autoUnavailableRows = [];
/**
* Lignes renvoyees par AllergenRepository::all() (14 allergenes INCO :
* id, code, name, description).
*
* @var list<array<string, mixed>>
*/
public array $allergensRows = [];
/** /**
* Trace des lectures pour asserter le court-circuit du detail (id <= 0). * Trace des lectures pour asserter le court-circuit du detail (id <= 0).
* *
@ -144,6 +152,10 @@ final class FakeCatalogueDatabase implements DatabaseInterface
return $this->menuSlotRows; return $this->menuSlotRows;
} }
if (str_contains($sql, 'FROM allergen')) {
return $this->allergensRows;
}
return []; return [];
} }

View file

@ -94,6 +94,32 @@ final class CatalogueControllerTest extends TestCase
self::assertSame(0, $payload['total']); self::assertSame(0, $payload['total']);
} }
public function testAllergensReturnsIncoCollectionWithDescription(): void
{
$db = new FakeCatalogueDatabase();
// Entiers en CHAINE (comme PDO peut les rendre) + une description null pour
// verifier la preservation du NULL.
$db->allergensRows = [
['id' => '1', 'code' => 'gluten', 'name' => 'Cereales contenant du gluten', 'description' => 'Ble, seigle, orge.'],
['id' => '7', 'code' => 'lait', 'name' => 'Lait', 'description' => null],
];
$response = $this->controller($db, '/api/allergens')->allergens();
self::assertSame(200, $response->status());
$payload = $this->decode($response->body());
self::assertSame(2, $payload['total']);
self::assertIsArray($payload['data']);
$first = $payload['data'][0];
self::assertSame(['id', 'code', 'name', 'description'], array_keys($first));
self::assertSame(1, $first['id']); // chaine '1' -> int 1
self::assertSame('gluten', $first['code']);
self::assertSame('Cereales contenant du gluten', $first['name']);
self::assertSame('Ble, seigle, orge.', $first['description']);
self::assertNull($payload['data'][1]['description']); // null preserve
}
public function testProductsReturnsAvailableCollectionWithoutVatRate(): void public function testProductsReturnsAvailableCollectionWithoutVatRate(): void
{ {
$db = new FakeCatalogueDatabase(); $db = new FakeCatalogueDatabase();

View file

@ -1,16 +1,14 @@
/* /*
* Tests du module allergens du front borne (node:test + jsdom). * 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, * Couvre : la construction du bouton "i", la modale GENERALE (ouverture, listing,
* se branchera sur /api/allergens au swap P4), la construction du bouton "i", et * fermeture par bouton/overlay/Escape, idempotence) et le chargement via l'API
* la modale GENERALE (ouverture, listing des 14, fermeture par bouton/overlay/ * (loadAllergens consomme /api/allergens et ramene la forme borne). Les cas de
* Escape, idempotence). DOM simule par jsdom : aucun navigateur requis. * rendu utilisent une fixture INLINE pour rester independants de la source de
* donnees. DOM simule par jsdom : aucun navigateur requis.
*/ */
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; 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 { JSDOM } from 'jsdom';
import { import {
@ -19,8 +17,20 @@ import {
closeAllergenModal, closeAllergenModal,
} from '../../src/public/borne/assets/js/allergens.js'; } from '../../src/public/borne/assets/js/allergens.js';
const here = dirname(fileURLToPath(import.meta.url)); let _seq = 0;
const allergensJsonPath = join(here, '../../src/public/borne/data/allergens.json');
/* Fixture INLINE : un echantillon des 14 allergenes INCO a la forme borne
* { id, name, description }. Suffisant pour couvrir le rendu de la modale sans
* dependre d'un fichier de donnees. */
function allergensFixture() {
return [
{ id: 1, name: 'Cereales contenant du gluten', description: 'Ble, seigle, orge, avoine.' },
{ 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.' },
{ id: 14, name: 'Mollusques', description: 'Et produits a base de mollusques.' },
];
}
function setupDom() { function setupDom() {
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>'); const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>');
@ -29,28 +39,6 @@ function setupDom() {
return dom; 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', () => { test('buildAllergenInfoButton cree un bouton "i" qui declenche onOpen', () => {
setupDom(); setupDom();
let opened = 0; let opened = 0;
@ -65,46 +53,78 @@ test('buildAllergenInfoButton cree un bouton "i" qui declenche onOpen', () => {
assert.equal(opened, 1, 'le clic ouvre la modale'); assert.equal(opened, 1, 'le clic ouvre la modale');
}); });
test('openAllergenModal affiche une modale listant les 14 allergenes', () => { test('openAllergenModal affiche une modale listant les allergenes fournis', () => {
setupDom(); setupDom();
const list = loadAllergensFixture(); const list = allergensFixture();
const overlay = openAllergenModal(list); const overlay = openAllergenModal(list);
assert.ok(document.body.contains(overlay)); assert.ok(document.body.contains(overlay));
assert.equal(overlay.getAttribute('role'), 'dialog'); assert.equal(overlay.getAttribute('role'), 'dialog');
assert.equal(overlay.getAttribute('aria-modal'), 'true'); assert.equal(overlay.getAttribute('aria-modal'), 'true');
const items = overlay.querySelectorAll('.allergen-modal-list li'); const items = overlay.querySelectorAll('.allergen-modal-list li');
assert.equal(items.length, 14); assert.equal(items.length, list.length);
assert.ok(overlay.textContent.toLowerCase().includes('lait')); assert.ok(overlay.textContent.toLowerCase().includes('lait'));
}); });
test('openAllergenModal affiche la description quand elle est fournie', () => {
setupDom();
const overlay = openAllergenModal([{ id: 7, name: 'Lait', description: 'Et produits a base de lait.' }]);
const desc = overlay.querySelector('.allergen-desc');
assert.ok(desc, 'la description doit etre rendue');
assert.ok(desc.textContent.toLowerCase().includes('lait'));
});
test('la modale se ferme via le bouton de fermeture', () => { test('la modale se ferme via le bouton de fermeture', () => {
setupDom(); setupDom();
openAllergenModal(loadAllergensFixture()); openAllergenModal(allergensFixture());
document.querySelector('.allergen-modal-close').click(); document.querySelector('.allergen-modal-close').click();
assert.equal(document.querySelector('.allergen-modal-overlay'), null); assert.equal(document.querySelector('.allergen-modal-overlay'), null);
}); });
test('la modale se ferme par clic sur l overlay (hors contenu)', () => { test('la modale se ferme par clic sur l overlay (hors contenu)', () => {
const dom = setupDom(); const dom = setupDom();
const overlay = openAllergenModal(loadAllergensFixture()); const overlay = openAllergenModal(allergensFixture());
overlay.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); overlay.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true }));
assert.equal(document.querySelector('.allergen-modal-overlay'), null); assert.equal(document.querySelector('.allergen-modal-overlay'), null);
}); });
test('la modale se ferme avec la touche Echap', () => { test('la modale se ferme avec la touche Echap', () => {
const dom = setupDom(); const dom = setupDom();
openAllergenModal(loadAllergensFixture()); openAllergenModal(allergensFixture());
document.dispatchEvent(new dom.window.KeyboardEvent('keydown', { key: 'Escape' })); document.dispatchEvent(new dom.window.KeyboardEvent('keydown', { key: 'Escape' }));
assert.equal(document.querySelector('.allergen-modal-overlay'), null); assert.equal(document.querySelector('.allergen-modal-overlay'), null);
}); });
test('ouvrir deux fois ne duplique pas la modale (idempotent)', () => { test('ouvrir deux fois ne duplique pas la modale (idempotent)', () => {
setupDom(); setupDom();
const list = loadAllergensFixture(); const list = allergensFixture();
openAllergenModal(list); openAllergenModal(list);
openAllergenModal(list); openAllergenModal(list);
assert.equal(document.querySelectorAll('.allergen-modal-overlay').length, 1); assert.equal(document.querySelectorAll('.allergen-modal-overlay').length, 1);
closeAllergenModal(); closeAllergenModal();
assert.equal(document.querySelector('.allergen-modal-overlay'), null); assert.equal(document.querySelector('.allergen-modal-overlay'), null);
}); });
test('loadAllergens consomme /api/allergens, deballe {data} et ramene la forme borne', async () => {
const calls = [];
// Reponse canonique de l'API : enveloppe { data, total }, entrees id/code/name/description.
const apiRows = [
{ id: 1, code: 'gluten', name: 'Cereales contenant du gluten', description: 'Ble, seigle, orge.' },
{ id: 7, code: 'lait', name: 'Lait', description: 'Et produits a base de lait.' },
];
global.fetch = async (url) => {
calls.push(url);
if (url !== '/api/allergens') throw new Error(`fetch inattendu: ${url}`);
return { ok: true, status: 200, json: async () => ({ data: apiRows, total: apiRows.length }) };
};
const { loadAllergens } = await import(`../../src/public/borne/assets/js/data.js?case=allergens${_seq++}`);
const list = await loadAllergens();
assert.ok(calls.includes('/api/allergens'), 'doit fetch /api/allergens');
assert.equal(list.length, 2);
// Forme borne : name + description presents, code ignore.
assert.deepEqual(list[0], { id: 1, name: 'Cereales contenant du gluten', description: 'Ble, seigle, orge.' });
assert.equal(list[1].name, 'Lait');
assert.equal(list[1].description, 'Et produits a base de lait.');
});