chore(borne): bascule allergenes sur /api/allergens + menage donnees/docs
All checks were successful
CI / secret-scan (pull_request) Successful in 15s
CI / php-lint (pull_request) Successful in 31s
CI / static-tests (pull_request) Successful in 1m25s
CI / js-tests (pull_request) Successful in 45s

Allergenes : AllergenRepository::all() et presentAllergen exposent desormais la
description (deja en base + seed) ; data.js consomme /api/allergens (via
fetchCollection) au lieu du JSON statique. La borne a une source unique pour les
allergenes.

Menage : suppression des fichiers de donnees morts (allergens.json,
categories.json, produits.json) que plus aucun code vivant ne lisait. README de
data/ reecrit ; commentaires perimes corriges (products.html, categories.html) ;
conventions.md (endpoints catalogue/allergens passes en livre) et
maquette-vs-build.md (panneau persistant, composeur modal, chevalet livres ;
product.html et cart.html retires) realignes sur l'etat reel du code.

Tests : allergens.test.js mocke /api/allergens (forme borne) + fixture inline ;
CatalogueControllerTest asserte la cle description ; AllergenReadDbTest renforce.
JS 112, PHP unit 405, PHPStan L6.
This commit is contained in:
Imugiii 2026-06-24 10:34:50 +00:00
parent 6bf3597b5e
commit 2cbd2ddb5f
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 |
|---|---|---|---|---|
| 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
statique sous `src/public/borne/data/` (voir section 8.3).
La borne (kiosk) consomme l'API REST `/api/*` en lecture pour le catalogue (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
(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.
@ -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
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
des sources de l'ecole et porte un nommage different et heterogene (`title`/`nom`, `prix`, `image`,
`type`). Ce contrat est fige par le brief ecole et consomme tel quel par le JS de la borne via
`data.js`.
Le front de la borne attend un nommage historique heterogene issu des sources de l'ecole
(`title`/`nom`, `prix`, `image`, `type`). L'API sert la forme canonique de 8.1
(`/api/categories`, `/api/products`, `/api/menus`, `/api/allergens`). Le rapprochement se fait
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
`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 |
| Forme borne | Canonique API / dictionnaire |
|---|---|
| `title` (categorie) | `name` |
| `nom` (produit) | `name` |

View file

@ -23,9 +23,12 @@ Accueil
-> Remerciement
```
Le kiosk construit, lui, eclate cet ecran unique en **pages distinctes** et n'a
pas de panneau de commande persistant. C'est l'origine du sentiment "ca ne
correspond pas".
Le kiosk construit a desormais rejoint ce paradigme : l'ecran de commande
(`products.html`) porte un **panneau de commande persistant** a droite, les options
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
@ -87,25 +90,29 @@ correspond pas".
| Maquette | Kiosk construit | Verdict |
|----------|-----------------|---------|
| 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) |
| 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) |
| 8. Modale d'option produit (taille + quantite) | `product.html` (page) | divergence : page au lieu de modale |
| 9. Ecran **chevalet** dedie (saisie numero) | numero gere par l'API (chunk 1a), affiche en confirmation | manquant cote ecran |
| 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-options.js` : **modale** d'options (taille R4 + stepper de quantite) au-dessus de la grille | conforme |
| 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 |
| 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
categories en bandeau et un panneau recapitulatif persistant a droite, modales
par-dessus). Build = **multi-pages** classiques (categories -> produits ->
produit -> panier). C'est l'ecart structurant principal.
2. **Panneau de commande lateral absent.** La piece centrale de la maquette
(numero de commande, lignes editables avec corbeille, TOTAL ttc, Abandon /
Payer, visible en permanence) n'est pas presente dans le build.
3. **Composition de menu.** Maquette = assistant modal en etapes ; build =
composition libre cote client (`page-product-menu.js`).
Les ecarts structurants du premier jet ont ete realignes sur la maquette :
1. **Paradigme.** L'ecran de commande (`products.html`) suit le plan mono-ecran de
la maquette : categories en bandeau (`category-strip.js`), grille produits, et
panneau recapitulatif persistant a droite ; les options et le composeur de menu
s'ouvrent en modale par-dessus. Les pages `product.html` et `cart.html` du
premier jet ont ete retirees.
2. **Panneau de commande lateral.** La piece centrale de la maquette (numero de
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
@ -116,6 +123,8 @@ note n'est donc pas le rebrand mais la **structure** des ecrans.
## 6. Suite
Re-alignement du kiosk sur la maquette (panneau persistant + bandeau categories +
composeur en modale) = chantier UI conduit via un cycle FD dedie. Backlog des
divergences = section 3 ci-dessus.
Le re-alignement du kiosk sur la maquette (panneau persistant + bandeau categories
+ composeur en modale + chevalet en modale) est livre. La borne lit le catalogue
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
* categories), pas un calcul par produit (le mapping ingredient_allergen reste
* differe). Sert l'endpoint public anonyme /api/allergens. Le schema ne porte que
* code + name ; les descriptions riches restent cote borne (data/allergens.json).
* differe). Sert l'endpoint public anonyme /api/allergens. Le schema porte
* 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).
*/
@ -27,6 +28,6 @@ class AllergenRepository
*/
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
* @return array{id: int, code: string, name: string}
* @return array{id: int, code: string, name: string, description: ?string}
*/
private function presentAllergen(array $row): array
{
@ -195,6 +195,7 @@ class CatalogueController extends Controller
'id' => (int) ($row['id'] ?? 0),
'code' => (string) ($row['code'] ?? ''),
'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
* 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).
* Les donnees viennent de data.js (loadAllergens), qui lit /api/allergens.
* openAllergenModal prend la liste en parametre pour rester independant de la
* couche de chargement (et testable sans fetch).
*/
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
* 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
* sur /api/allergens est un chunk ulterieur.
* Les allergenes sont desormais lus depuis /api/allergens (id/code/name/description),
* comme les autres collections catalogue : le repli statique a ete retire.
*/
const CATEGORIES_URL = '/api/categories';
const PRODUCTS_URL = '/api/products';
const MENUS_URL = '/api/menus';
/* Liste fixe des 14 allergenes INCO (info generale, modale borne). L'endpoint
* /api/allergens existe desormais (id/code/name), mais la borne garde ce JSON
* statique : il porte les DESCRIPTIONS riches, absentes du schema allergen. Bascule
* possible si les descriptions sont ajoutees cote API. */
const ALLERGENS_URL = 'data/allergens.json';
/* Les 14 allergenes INCO (info generale, modale borne). L'endpoint /api/allergens
* porte id/code/name/description (la description INCO est seede en base) -> la borne
* la consomme via l'API, comme les autres collections catalogue. */
const ALLERGENS_URL = '/api/allergens';
/* Memoisation par PROMESSE (pas par resultat) : N appelants concurrents au meme
* 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 :
* la reponse est un tableau nu (pas d'enveloppe), conserve tel quel.
* @returns {Promise<Array>}
* Fetches and caches the 14 INCO allergens (general info modal). Consomme
* /api/allergens (enveloppe { data }, forme canonique id/code/name/description) et
* 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() {
if (!_allergensPromise) {
_allergensPromise = fetch(ALLERGENS_URL)
.then(res => {
if (!res.ok) throw new Error(`Failed to load allergens: HTTP ${res.status}`);
return res.json();
})
_allergensPromise = fetchCollection(ALLERGENS_URL)
.then(rows => rows.map(a => ({ id: a.id, name: a.name, description: a.description ?? null })))
.catch(e => { _allergensPromise = null; throw e; });
}
return _allergensPromise;

View file

@ -13,11 +13,11 @@
<body class="categories-page">
<!--
Categories screen.
Data source: docs/merise/_sources/categories.json (9 categories).
Categories screen — static scaffold (9 categories) listed in catalogue order.
Image paths: assets/images/categories/{title}.png — verified against filesystem.
In P4 this page will be generated dynamically from GET /api/categories.
For now it is a static scaffold that matches the data contract exactly.
The cards link to products.html?category=<id>; the product/menu/allergen data
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">
@ -41,10 +41,9 @@
<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.
Each card links to a product page (products.html?category=<id>) — stub URL
for future P5 implementation. The link is functional HTML; no JS needed.
title field from JSON used as alt text and visible label.
9 categories in catalogue order. Each card links to a product page
(products.html?category=<id>). The link is functional HTML; no JS needed.
The category title is used as alt text and visible label.
-->
<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
le front de la borne (Bloc 1 / P5) tant que l'API REST n'existe pas. Ils sont
copies du jeu de donnees source de l'ecole (`docs/merise/_sources/`), **pas**
generes depuis la base.
La borne consomme l'API REST en lecture : `/api/categories`, `/api/products`,
`/api/menus` et `/api/allergens` (cf. `docs/api/conventions.md` section 5.2). La
couche `assets/js/data.js` deballe l'enveloppe `{ data }` et traduit la forme
canonique vers la forme attendue par les pages.
## Ces fichiers ne refletent pas la base
Le catalogue servi ici est le jeu source complet (66 produits) ; le seed de la
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).
Les anciens fichiers JSON statiques (`categories.json`, `produits.json`,
`allergens.json`) qui servaient de repli avant l'API ont ete retires : la borne
reflete la base via l'API.

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.
Category is determined at runtime from ?category=<id>.
JS (page-products.js) fetches data/produits.json and renders cards.
In P4: swap fetch URL in data.js to point to GET /api/products?category=<slug>.
JS (page-products.js) reads the catalogue via data.js, which fetches
GET /api/products (and /api/categories, /api/menus), then renders cards.
-->
<header class="site-header">

View file

@ -13,7 +13,8 @@ use App\Core\Database;
/**
* AllergenRepository contre une vraie MariaDB (schema migre + seed reference).
* 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
{
@ -34,7 +35,7 @@ final class AllergenReadDbTest extends TestCase
}
}
public function testListsIncoReferenceWithCodeAndName(): void
public function testListsIncoReferenceWithCodeNameAndDescription(): void
{
$rows = (new AllergenRepository($this->db))->all();
@ -42,7 +43,11 @@ final class AllergenReadDbTest extends TestCase
foreach ($rows as $a) {
self::assertArrayHasKey('code', $a);
self::assertArrayHasKey('name', $a);
self::assertArrayHasKey('description', $a);
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 = [];
/**
* 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).
*
@ -144,6 +152,10 @@ final class FakeCatalogueDatabase implements DatabaseInterface
return $this->menuSlotRows;
}
if (str_contains($sql, 'FROM allergen')) {
return $this->allergensRows;
}
return [];
}

View file

@ -94,6 +94,32 @@ final class CatalogueControllerTest extends TestCase
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
{
$db = new FakeCatalogueDatabase();

View file

@ -1,16 +1,14 @@
/*
* 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.
* Couvre : la construction du bouton "i", la modale GENERALE (ouverture, listing,
* fermeture par bouton/overlay/Escape, idempotence) et le chargement via l'API
* (loadAllergens consomme /api/allergens et ramene la forme borne). Les cas de
* 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 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 {
@ -19,8 +17,20 @@ import {
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');
let _seq = 0;
/* 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() {
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>');
@ -29,28 +39,6 @@ function setupDom() {
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;
@ -65,46 +53,78 @@ test('buildAllergenInfoButton cree un bouton "i" qui declenche onOpen', () => {
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();
const list = loadAllergensFixture();
const list = allergensFixture();
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.equal(items.length, list.length);
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', () => {
setupDom();
openAllergenModal(loadAllergensFixture());
openAllergenModal(allergensFixture());
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());
const overlay = openAllergenModal(allergensFixture());
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());
openAllergenModal(allergensFixture());
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();
const list = allergensFixture();
openAllergenModal(list);
openAllergenModal(list);
assert.equal(document.querySelectorAll('.allergen-modal-overlay').length, 1);
closeAllergenModal();
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.');
});