chore(borne): bascule allergenes sur /api/allergens + menage donnees/docs (#103)
This commit is contained in:
parent
3c53908952
commit
6f2aedc699
16 changed files with 185 additions and 242 deletions
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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." }
|
||||
]
|
||||
|
|
@ -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" }
|
||||
]
|
||||
|
|
@ -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" }
|
||||
]
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue