feat(borne): composeur menu pilote par les slots /api/menus (P5 L2)
All checks were successful
CI / secret-scan (pull_request) Successful in 12s
CI / php-lint (pull_request) Successful in 26s
CI / static-tests (pull_request) Successful in 50s
CI / js-tests (pull_request) Successful in 33s
CI / js-tests (push) Successful in 30s
CI / secret-scan (push) Successful in 11s
CI / php-lint (push) Successful in 26s
CI / static-tests (push) Successful in 51s

Le composeur de menu consomme desormais GET /api/menus/{id} au lieu de composer
librement a partir des categories (dette P4 #3). Burger impose (burger_product_id),
un pas par slot reel (drink/side/sauce, options resolues), format Normal/Maxi au
prix du menu.

- data.js : loadMenu(id) (detail + slots) ; loadProductsById (index produit par id,
  type 'produit' uniquement -> pas de collision avec les ids de menu).
- page-product-menu.js : reecrit slot-driven. Fonctions pures buildComposerSteps /
  buildMenuCartItem / selectionsComplete / composerIsViable. composition produite
  compatible avec page-cart.js et le panneau persistant L1 (slot_type -> champ).
- page-cart.js : rendu de composition tolerant aux champs absents ; libelle Maxi
  ("Format Maxi : +X") au lieu de "N grande(s)" devenu trompeur sous le forfait menu.
- tests : composer-slots.test.js + loadProductsById (data.test.js). 49/49 verts.

Revue adversariale (workflow, 14 findings / 12 confirmes) : must-fix integres --
SLOT_FIELD fait foi (slot_type hors drink/side/sauce ignore, l'enum DB autorise
dessert/extra -> plus de choix perdu) ; composerIsViable refuse d'ouvrir si burger
absent ou slot requis sans option (plus d'etape impassable) ; aria-hidden du fond
sous la modale ; role=dialog sur le conteneur ; overflow body sauvegarde/restaure.
Differes notes : multi-slots de meme type (menu famille), display_order vs maquette.
This commit is contained in:
Imugiii 2026-06-19 16:28:22 +00:00
parent c73afdf471
commit 25f47a5074
5 changed files with 472 additions and 556 deletions

View file

@ -109,6 +109,45 @@ export async function loadProducts() {
return _productsCache; return _productsCache;
} }
/** @type {Object|null} — cache id->produit (type 'produit' uniquement) */
let _productsByIdCache = null;
/**
* Index des PRODUITS par id (type 'produit' seulement : exclut les menus, dont
* l'espace d'id est distinct -> pas de collision id produit/menu). Sert au composeur
* de menu (L2) pour resoudre les option_product_ids des slots /api/menus en produits
* affichables. Derive de loadProducts() : aucune requete reseau supplementaire.
* @returns {Promise<Object<number, Object>>}
*/
export async function loadProductsById() {
if (_productsByIdCache) return _productsByIdCache;
const bySlug = await loadProducts();
const byId = {};
for (const slug of Object.keys(bySlug)) {
for (const item of bySlug[slug]) {
if (item.type === 'produit') byId[item.id] = item;
}
}
_productsByIdCache = byId;
return _productsByIdCache;
}
/**
* Charge le detail d'un menu avec ses slots depuis GET /api/menus/{id}. Renvoie la
* forme canonique de l'API (snake_case) telle quelle : { id, burger_product_id,
* price_normal_cents, price_maxi_cents, name, image_path, slots: [{ id, name,
* slot_type, is_required, display_order, option_product_ids }] }. Le composeur (L2)
* la traduit en etapes. Pas de cache : un menu est compose ponctuellement.
* @param {number} id
* @returns {Promise<Object|null>}
*/
export async function loadMenu(id) {
const res = await fetch(`${MENUS_URL}/${id}`);
if (!res.ok) throw new Error(`Failed to load menu ${id}: HTTP ${res.status}`);
const body = await res.json();
return body && typeof body === 'object' ? (body.data ?? null) : null;
}
/** /**
* Fetches and caches the 14 INCO allergens (general info modal). Repli statique : * Fetches and caches the 14 INCO allergens (general info modal). Repli statique :
* la reponse est un tableau nu (pas d'enveloppe), conserve tel quel. * la reponse est un tableau nu (pas d'enveloppe), conserve tel quel.

View file

@ -151,23 +151,31 @@ function renderCompositionBlock(item) {
const c = item.composition; const c = item.composition;
if (!c) return ''; if (!c) return '';
const burgerOpts = c.burger.options && c.burger.options.length // Tolerant aux champs absents : depuis L2 (composeur slot-driven), un menu peut
? ` (${c.burger.options.map(o => o === 'sans-oignon' ? 'sans oignon' : 'avec fromage').join(', ')})` // ne pas avoir tous les slots (ex. pas de sauce) -> ne pas supposer leur presence.
: ''; const parts = [];
if (c.burger) {
const burgerOpts = c.burger.options && c.burger.options.length
? ` (${c.burger.options.map(o => o === 'sans-oignon' ? 'sans oignon' : 'avec fromage').join(', ')})`
: '';
parts.push(`${escHtml(c.burger.libelle)}${burgerOpts}`);
}
if (c.accompagnement) {
parts.push(`${escHtml(c.accompagnement.libelle)}${c.accompagnement.taille === 'G' ? ' grande' : ' normale'}`);
}
if (c.boisson) {
parts.push(`${escHtml(c.boisson.libelle)}${c.boisson.taille === 'G' ? ' grande' : ' normale'}`);
}
if (c.sauce) {
parts.push(escHtml(c.sauce.libelle));
}
const accompTailleLabel = c.accompagnement.taille === 'G' ? ' grande' : ' normale';
const boissonTailleLabel = c.boisson.taille === 'G' ? ' grande' : ' normale';
const nbGrandes = (c.accompagnement.taille === 'G' ? 1 : 0) + (c.boisson.taille === 'G' ? 1 : 0);
const supplTotal = item.supplement_cents ?? 0; const supplTotal = item.supplement_cents ?? 0;
return ` return `
<ul class="cart-line__composition" aria-label="Composition du menu"> <ul class="cart-line__composition" aria-label="Composition du menu">
<li class="cart-line__comp-item">+ ${escHtml(c.burger.libelle)}${burgerOpts}</li> ${parts.map(t => `<li class="cart-line__comp-item">+ ${t}</li>`).join('')}
<li class="cart-line__comp-item">+ ${escHtml(c.accompagnement.libelle)}${accompTailleLabel}</li> ${supplTotal > 0 ? `<li class="cart-line__comp-suppl">Format Maxi : +${formatPrice(supplTotal)}</li>` : ''}
<li class="cart-line__comp-item">+ ${escHtml(c.boisson.libelle)}${boissonTailleLabel}</li>
<li class="cart-line__comp-item">+ ${escHtml(c.sauce.libelle)}</li>
${supplTotal > 0 ? `<li class="cart-line__comp-suppl">Supplement ${nbGrandes} grande(s) : +${formatPrice(supplTotal)}</li>` : ''}
</ul> </ul>
`; `;
} }

View file

@ -1,466 +1,364 @@
/* /*
* page-product-menu.js Multi-step menu composer for the Wakdo kiosk. * page-product-menu.js Composeur de menu PILOTE PAR LES SLOTS (P5 L2).
* *
* Imported by page-product.js only when the loaded product has type === 'menu'. * Importe par page-product.js quand le produit charge est un menu (type === 'menu').
* Keeping the composer in its own module avoids bloating page-product.js and
* makes future unit-testing of the composition logic straightforward.
* *
* Steps: * Avant L2 : le composeur composait LIBREMENT a partir des categories (burgers,
* 1 Burger selection + personalisation options (sans oignon / avec fromage) * frites, boissons, sauces) sans tenir compte du menu reel. Desormais il consomme
* 2 Accompagnement (frites or salades) + taille toggle * GET /api/menus/{id} : le burger est IMPOSE (burger_product_id), et chaque slot
* 3 Boisson + taille toggle * (slot_type drink/side/sauce, option_product_ids) devient une etape. Le prix vient
* 4 Sauce * du menu (Normal vs Maxi), pas d'un supplement arbitraire.
* 5 Recap + "Ajouter au panier"
* *
* Price rule: grande taille = +50 centimes per sized item (accompagnement + boisson). * Etapes : Format (Normal/Maxi, burger impose affiche) -> 1 pas par slot (dans
* l'ordre display_order ; requis = choix obligatoire, optionnel = "sans") -> recap.
* *
* A11y: role=dialog, aria-modal=true, focus-trap (Tab cycles inside the modal), * La forme de `composition` produite reste compatible avec page-cart.js et
* ESC closes/cancels, focus is moved to the first interactive element on each step. * order-panel.js (burger / accompagnement / boisson / sauce + taille), le slot_type
* mappant vers le bon champ ; Maxi pose taille 'G' + supplement = prix_maxi - prix_normal.
*
* A11y : role=dialog, aria-modal, focus-trap, ESC annule, focus au 1er interactif.
*/ */
import { getProductsByCategory } from './data.js'; import { loadMenu, loadProductsById } from './data.js';
import { addToCart, computeMenuLineCents, formatPrice } from './state.js'; import { addToCart, computeMenuLineCents, formatPrice, escHtml } from './state.js';
import { refreshCartBadge } from './nav.js'; import { refreshCartBadge } from './nav.js';
const SUPPLEMENT_GRANDE_CENTS = 50; /* slot_type de l'API -> champ de composition attendu par le rendu panier existant. */
const TOTAL_STEPS = 5; const SLOT_FIELD = { side: 'accompagnement', drink: 'boisson', sauce: 'sauce' };
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* Public entry-point — called from page-product.js */ /* Fonctions PURES (cible des tests, sans DOM ni fetch) */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/** /**
* Initialises and opens the menu composer modal. * Construit le modele d'etapes a partir du detail menu (slots) et de l'index
* Fetches required category products, builds the initial state, then renders. * produit par id. Resout les option_product_ids en produits affichables, trie les
* * slots par display_order. Pur.
* @param {Object} menu product object with type === 'menu' * @param {Object} detail sortie de loadMenu()
* @param {string} returnCategory category slug to redirect to after add/cancel * @param {Object<number,Object>} byId sortie de loadProductsById()
* @returns {{burger: Object|null, slots: Array, priceNormalCents: number, priceMaxiCents: number}}
*/
export function buildComposerSteps(detail, byId) {
const burger = byId[detail.burger_product_id] ?? null;
const slots = [...(detail.slots ?? [])]
// SLOT_FIELD fait foi : un slot_type non mappe (l'enum DB autorise aussi
// dessert/extra) ne devient PAS une etape -> pas de choix perdu silencieusement.
.filter(slot => {
if (SLOT_FIELD[slot.slot_type]) return true;
console.warn(`Menu composer: slot_type non gere, slot ignore: ${slot.slot_type}`);
return false;
})
.sort((a, b) => (a.display_order ?? 0) - (b.display_order ?? 0))
.map(slot => ({
id: slot.id,
name: slot.name,
slotType: slot.slot_type,
isRequired: !!slot.is_required,
options: (slot.option_product_ids ?? []).map(pid => byId[pid]).filter(Boolean),
}));
return {
burger,
slots,
priceNormalCents: detail.price_normal_cents,
priceMaxiCents: detail.price_maxi_cents,
};
}
/**
* Construit l'item panier du menu compose. `composition` reste compatible avec le
* rendu existant (burger/accompagnement/boisson/sauce). Maxi -> taille 'G' sur les
* items dimensionnables + supplement = prix_maxi - prix_normal (prix_cents = normal).
* @param {Object} menu produit borne {id, nom, image, prix?}
* @param {Object} model sortie de buildComposerSteps
* @param {{size: 'N'|'M', selections: Object<number, number>}} choice
* @returns {Object} item panier
*/
export function buildMenuCartItem(menu, model, { size, selections }) {
const isMaxi = size === 'M';
const taille = isMaxi ? 'G' : 'N';
const supplement = isMaxi
? Math.max(0, (model.priceMaxiCents ?? 0) - (model.priceNormalCents ?? 0))
: 0;
const composition = {
burger: { id: model.burger?.id, libelle: model.burger?.nom ?? menu.nom, options: [] },
};
for (const slot of model.slots) {
const chosen = slot.options.find(o => o.id === selections[slot.id]);
if (!chosen) continue; // slot optionnel laisse "sans"
const field = SLOT_FIELD[slot.slotType];
if (!field) continue;
composition[field] = field === 'sauce'
? { id: chosen.id, libelle: chosen.nom }
: { id: chosen.id, libelle: chosen.nom, taille };
}
return {
id: menu.id,
type: 'menu',
categorie: 'menus',
libelle: menu.nom,
prix_cents: model.priceNormalCents,
quantite: 1,
image: menu.image,
supplement_cents: supplement,
composition,
};
}
/**
* Indique si toutes les etapes obligatoires ont une selection. Pur.
* @param {Object} model
* @param {Object<number,number>} selections
* @returns {boolean}
*/
export function selectionsComplete(model, selections) {
return model.slots
.filter(s => s.isRequired)
.every(s => s.options.some(o => o.id === selections[s.id]));
}
/**
* Le menu est-il composable ? Faux si le burger impose est introuvable, ou si un
* slot REQUIS n'a aucune option resolue (catalogue desync). Pur. Garde-fou : eviter
* d'ouvrir une modale ou une etape requise serait impassable.
* @param {Object} model
* @returns {boolean}
*/
export function composerIsViable(model) {
if (!model.burger) return false;
return model.slots.filter(s => s.isRequired).every(s => s.options.length > 0);
}
/* ------------------------------------------------------------------ */
/* Entree publique — appelee par page-product.js */
/* ------------------------------------------------------------------ */
/**
* Initialise et ouvre la modale du composeur pour un menu.
* @param {Object} menu produit borne avec type === 'menu'
* @param {string} returnCategory slug de categorie de retour apres ajout/annulation
*/ */
export async function openMenuComposer(menu, returnCategory) { export async function openMenuComposer(menu, returnCategory) {
let burgers, frites, salades, boissons, sauces; let detail, byId;
try { try {
[burgers, frites, salades, boissons, sauces] = await Promise.all([ [detail, byId] = await Promise.all([loadMenu(menu.id), loadProductsById()]);
getProductsByCategory('burgers'),
getProductsByCategory('frites'),
getProductsByCategory('salades'),
getProductsByCategory('boissons'),
getProductsByCategory('sauces')
]);
} catch (err) { } catch (err) {
console.error('Menu composer: failed to load category products', err); console.error('Menu composer: chargement /api/menus echoue', err);
return;
}
if (!detail) {
console.error('Menu composer: detail menu introuvable', menu.id);
return; return;
} }
const accompagnements = [...frites, ...salades]; const model = buildComposerSteps(detail, byId);
if (!composerIsViable(model)) {
console.error('Menu composer: menu non composable (burger absent ou slot requis sans option)', menu.id);
return;
}
/* Heuristic pre-selection: if the menu name contains a burger name, pre-select it.
* "Menu CBO" -> first burger whose nom equals "CBO".
* Fallback: first burger in the list. */
const menuNameUpper = menu.nom.toUpperCase();
const preselectedBurger =
burgers.find(b => menuNameUpper.includes(b.nom.toUpperCase())) ?? burgers[0] ?? null;
/* Composer internal state — single mutable object, re-read on each render. */
const state = { const state = {
currentStep: 1,
menu, menu,
returnCategory, returnCategory,
burgers, model,
accompagnements, size: 'N', // 'N' (Normal) | 'M' (Maxi)
boissons, selections: {}, // slotId -> productId ; pre-selection du 1er requis
sauces, currentStep: 0, // 0 = format ; 1..N = slots ; N+1 = recap
/* Selections */
burger: preselectedBurger,
burgerOptions: [], // subset of ['sans-oignon', 'avec-fromage']
accompagnement: accompagnements[0] ?? null,
accompTaille: 'N', // 'N' or 'G'
boisson: boissons[0] ?? null,
boissonTaille: 'N',
sauce: sauces[0] ?? null
}; };
for (const slot of model.slots) {
if (slot.isRequired && slot.options[0]) state.selections[slot.id] = slot.options[0].id;
}
const modal = buildModalShell(menu); const modal = buildModalShell(menu);
modal._prevOverflow = document.body.style.overflow;
document.body.appendChild(modal); document.body.appendChild(modal);
modal.removeAttribute('hidden'); modal.removeAttribute('hidden');
/* Prevent background scroll while composer is open. */
document.body.style.overflow = 'hidden'; document.body.style.overflow = 'hidden';
// Focus-trap : neutralise le fond pour les lecteurs d'ecran tant que la modale
// est ouverte (freres de l'overlay : header, .order-layout).
modal._bgSiblings = Array.from(document.body.children).filter(el => el !== modal);
modal._bgSiblings.forEach(el => el.setAttribute('aria-hidden', 'true'));
renderStep(modal, state); renderStep(modal, state);
trapFocus(modal); trapFocus(modal);
/* ESC closes the modal and returns to product list. */
const escHandler = (e) => { const escHandler = (e) => {
if (e.key === 'Escape') { if (e.key === 'Escape') cancelComposer(modal, returnCategory, escHandler);
cancelComposer(modal, returnCategory, escHandler);
}
}; };
document.addEventListener('keydown', escHandler); document.addEventListener('keydown', escHandler);
modal._escHandler = escHandler;
} }
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* Modal shell builder */ /* Coque modale */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
function totalSteps(state) {
return state.model.slots.length + 2; // format + slots + recap
}
function buildModalShell(menu) { function buildModalShell(menu) {
const overlay = document.createElement('div'); const overlay = document.createElement('div');
overlay.className = 'composer-overlay'; overlay.className = 'composer-overlay';
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
overlay.setAttribute('aria-labelledby', 'composer-title');
overlay.hidden = true; overlay.hidden = true;
overlay.innerHTML = ` overlay.innerHTML = `
<div class="composer-container" role="document"> <div class="composer-container" role="dialog" aria-modal="true" aria-labelledby="composer-title">
<div class="composer-header"> <div class="composer-header">
<h2 class="composer-title" id="composer-title">${escHtml(menu.nom)}</h2> <h2 class="composer-title" id="composer-title">${escHtml(menu.nom)}</h2>
<div class="composer-progress" aria-label="Progression"> <div class="composer-progress" aria-label="Progression">
<span class="composer-progress__text" id="composer-step-indicator" aria-live="polite">Etape 1 / ${TOTAL_STEPS}</span> <span class="composer-progress__text" id="composer-step-indicator" aria-live="polite"></span>
<div class="composer-progress__bar"> <div class="composer-progress__bar">
<div class="composer-progress__fill" id="composer-progress-fill" style="width: 20%"></div> <div class="composer-progress__fill" id="composer-progress-fill"></div>
</div> </div>
</div> </div>
</div> </div>
<div class="composer-body" id="composer-body"> <div class="composer-body" id="composer-body"></div>
<!-- step content injected here --> <div class="composer-footer" id="composer-footer"></div>
</div>
<div class="composer-footer" id="composer-footer">
<!-- navigation buttons injected here -->
</div>
</div> </div>
`; `;
return overlay; return overlay;
} }
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* Step renderer — decides which step to paint */ /* Rendu d'etape */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
function renderStep(modal, state) { function renderStep(modal, state) {
const body = modal.querySelector('#composer-body'); const body = modal.querySelector('#composer-body');
const footer = modal.querySelector('#composer-footer'); const footer = modal.querySelector('#composer-footer');
const stepEl = modal.querySelector('#composer-step-indicator'); const stepEl = modal.querySelector('#composer-step-indicator');
const fillEl = modal.querySelector('#composer-progress-fill'); const fillEl = modal.querySelector('#composer-progress-fill');
stepEl.textContent = `Etape ${state.currentStep} / ${TOTAL_STEPS}`; const total = totalSteps(state);
fillEl.style.width = `${(state.currentStep / TOTAL_STEPS) * 100}%`; stepEl.textContent = `Etape ${state.currentStep + 1} / ${total}`;
fillEl.style.width = `${((state.currentStep + 1) / total) * 100}%`;
/* Each step renderer returns {bodyHTML, canAdvance()} and may attach if (state.currentStep === 0) {
* its own event listeners after DOM insertion. */ renderFormatStep(body, footer, modal, state);
switch (state.currentStep) { } else if (state.currentStep <= state.model.slots.length) {
case 1: renderStep1(body, footer, modal, state); break; renderSlotStep(body, footer, modal, state, state.model.slots[state.currentStep - 1]);
case 2: renderStep2(body, footer, modal, state); break; } else {
case 3: renderStep3(body, footer, modal, state); break; renderRecapStep(body, footer, modal, state);
case 4: renderStep4(body, footer, modal, state); break;
case 5: renderStep5(body, footer, modal, state); break;
} }
/* Move focus to the first interactive element so keyboard users and
* screen readers start at the right place after each step transition. */
requestAnimationFrame(() => { requestAnimationFrame(() => {
const first = modal.querySelector( const first = modal.querySelector('button:not([disabled]), [tabindex="0"]');
'button:not([disabled]), input:not([disabled]), [tabindex="0"]'
);
if (first) first.focus(); if (first) first.focus();
}); });
} }
/* ------------------------------------------------------------------ */ /* Etape 0 — Format Normal / Maxi (burger impose affiche) */
/* Step 1 — Burger + personalisation options */ function renderFormatStep(body, footer, modal, state) {
/* ------------------------------------------------------------------ */ const { model } = state;
const burgerName = model.burger ? escHtml(model.burger.nom) : escHtml(state.menu.nom);
function renderStep1(body, footer, modal, state) {
body.innerHTML = ` body.innerHTML = `
<p class="composer-step__subtitle">Choisissez votre burger</p> <p class="composer-step__subtitle">Votre menu : ${burgerName}</p>
<ul class="composer-grid" role="list" id="burger-grid"> <div class="composer-taille" role="group" aria-label="Format du menu">
${state.burgers.map(b => ` <button class="composer-card ${state.size === 'N' ? 'composer-card--selected' : ''}"
<li> type="button" data-size="N" aria-pressed="${state.size === 'N'}">
<button <span class="composer-card__name">Normal</span>
class="composer-card ${state.burger && state.burger.id === b.id ? 'composer-card--selected' : ''}" <span class="composer-card__price">${formatPrice(model.priceNormalCents)}</span>
type="button" </button>
data-id="${b.id}" <button class="composer-card ${state.size === 'M' ? 'composer-card--selected' : ''}"
aria-pressed="${state.burger && state.burger.id === b.id ? 'true' : 'false'}" type="button" data-size="M" aria-pressed="${state.size === 'M'}">
aria-label="${escHtml(b.nom)}, ${formatPrice(b.prix)}" <span class="composer-card__name">Maxi</span>
> <span class="composer-card__price">${formatPrice(model.priceMaxiCents)}</span>
<img </button>
class="composer-card__image" </div>
src="${escHtml(b.image)}"
alt="${escHtml(b.nom)}"
onerror="this.src='assets/images/ui/logo.png';"
>
<span class="composer-card__name">${escHtml(b.nom)}</span>
<span class="composer-card__price">${formatPrice(b.prix)}</span>
</button>
</li>
`).join('')}
</ul>
<fieldset class="composer-options" id="burger-options">
<legend class="composer-options__legend">Personnalisation</legend>
<label class="composer-option-label">
<input type="checkbox" name="burger-opt" value="sans-oignon"
${state.burgerOptions.includes('sans-oignon') ? 'checked' : ''}>
Sans oignon
</label>
<label class="composer-option-label">
<input type="checkbox" name="burger-opt" value="avec-fromage"
${state.burgerOptions.includes('avec-fromage') ? 'checked' : ''}>
Avec fromage
</label>
</fieldset>
`; `;
body.querySelectorAll('[data-size]').forEach(btn => {
/* Burger card selection */
body.querySelectorAll('#burger-grid .composer-card').forEach(btn => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
const id = parseInt(btn.dataset.id, 10); state.size = btn.dataset.size;
state.burger = state.burgers.find(b => b.id === id) ?? state.burger; body.querySelectorAll('[data-size]').forEach(b => {
/* Update pressed states without full re-render to preserve scroll position */ const active = b.dataset.size === state.size;
body.querySelectorAll('#burger-grid .composer-card').forEach(b => {
const active = parseInt(b.dataset.id, 10) === state.burger.id;
b.classList.toggle('composer-card--selected', active); b.classList.toggle('composer-card--selected', active);
b.setAttribute('aria-pressed', active ? 'true' : 'false'); b.setAttribute('aria-pressed', active ? 'true' : 'false');
}); });
}); });
}); });
renderFooter(footer, modal, state, { canAdvance: () => true });
/* Personalisation checkboxes */
body.querySelectorAll('input[name="burger-opt"]').forEach(cb => {
cb.addEventListener('change', () => {
state.burgerOptions = Array.from(
body.querySelectorAll('input[name="burger-opt"]:checked')
).map(el => el.value);
});
});
renderFooter(footer, modal, state, {
canAdvance: () => state.burger !== null
});
} }
/* ------------------------------------------------------------------ */ /* Etapes 1..N — un slot (drink/side/sauce) */
/* Step 2 — Accompagnement + taille toggle */ function renderSlotStep(body, footer, modal, state, slot) {
/* ------------------------------------------------------------------ */ const optional = !slot.isRequired;
function renderStep2(body, footer, modal, state) {
body.innerHTML = ` body.innerHTML = `
<p class="composer-step__subtitle">Choisissez votre accompagnement</p> <p class="composer-step__subtitle">${escHtml(slot.name)}${optional ? ' (optionnel)' : ''}</p>
<ul class="composer-grid" role="list" id="accomp-grid"> <ul class="composer-grid" role="list" id="slot-grid">
${state.accompagnements.map(a => ` ${optional ? `
<li> <li>
<button <button class="composer-card ${state.selections[slot.id] == null ? 'composer-card--selected' : ''}"
class="composer-card ${state.accompagnement && state.accompagnement.id === a.id ? 'composer-card--selected' : ''}" type="button" data-pid="" aria-pressed="${state.selections[slot.id] == null}">
type="button" <span class="composer-card__name">Sans</span>
data-id="${a.id}"
aria-pressed="${state.accompagnement && state.accompagnement.id === a.id ? 'true' : 'false'}"
aria-label="${escHtml(a.nom)}"
>
<img
class="composer-card__image"
src="${escHtml(a.image)}"
alt="${escHtml(a.nom)}"
onerror="this.src='assets/images/ui/logo.png';"
>
<span class="composer-card__name">${escHtml(a.nom)}</span>
</button> </button>
</li> </li>` : ''}
`).join('')} ${slot.options.map(o => `
</ul>
${renderTailleToggle('accomp', state.accompTaille)}
`;
body.querySelectorAll('#accomp-grid .composer-card').forEach(btn => {
btn.addEventListener('click', () => {
const id = parseInt(btn.dataset.id, 10);
state.accompagnement = state.accompagnements.find(a => a.id === id) ?? state.accompagnement;
body.querySelectorAll('#accomp-grid .composer-card').forEach(b => {
const active = parseInt(b.dataset.id, 10) === state.accompagnement.id;
b.classList.toggle('composer-card--selected', active);
b.setAttribute('aria-pressed', active ? 'true' : 'false');
});
});
});
attachTailleToggle(body, 'accomp', state, 'accompTaille');
renderFooter(footer, modal, state, {
canAdvance: () => state.accompagnement !== null
});
}
/* ------------------------------------------------------------------ */
/* Step 3 — Boisson + taille toggle */
/* ------------------------------------------------------------------ */
function renderStep3(body, footer, modal, state) {
body.innerHTML = `
<p class="composer-step__subtitle">Choisissez votre boisson</p>
<ul class="composer-grid" role="list" id="boisson-grid">
${state.boissons.map(b => `
<li> <li>
<button <button class="composer-card ${state.selections[slot.id] === o.id ? 'composer-card--selected' : ''}"
class="composer-card ${state.boisson && state.boisson.id === b.id ? 'composer-card--selected' : ''}" type="button" data-pid="${o.id}"
type="button" aria-pressed="${state.selections[slot.id] === o.id}"
data-id="${b.id}" aria-label="${escHtml(o.nom)}">
aria-pressed="${state.boisson && state.boisson.id === b.id ? 'true' : 'false'}" <img class="composer-card__image" src="${escHtml(o.image)}" alt="${escHtml(o.nom)}"
aria-label="${escHtml(b.nom)}" onerror="this.src='assets/images/ui/logo.png';">
> <span class="composer-card__name">${escHtml(o.nom)}</span>
<img
class="composer-card__image"
src="${escHtml(b.image)}"
alt="${escHtml(b.nom)}"
onerror="this.src='assets/images/ui/logo.png';"
>
<span class="composer-card__name">${escHtml(b.nom)}</span>
</button>
</li>
`).join('')}
</ul>
${renderTailleToggle('boisson', state.boissonTaille)}
`;
body.querySelectorAll('#boisson-grid .composer-card').forEach(btn => {
btn.addEventListener('click', () => {
const id = parseInt(btn.dataset.id, 10);
state.boisson = state.boissons.find(b => b.id === id) ?? state.boisson;
body.querySelectorAll('#boisson-grid .composer-card').forEach(b => {
const active = parseInt(b.dataset.id, 10) === state.boisson.id;
b.classList.toggle('composer-card--selected', active);
b.setAttribute('aria-pressed', active ? 'true' : 'false');
});
});
});
attachTailleToggle(body, 'boisson', state, 'boissonTaille');
renderFooter(footer, modal, state, {
canAdvance: () => state.boisson !== null
});
}
/* ------------------------------------------------------------------ */
/* Step 4 — Sauce */
/* ------------------------------------------------------------------ */
function renderStep4(body, footer, modal, state) {
body.innerHTML = `
<p class="composer-step__subtitle">Choisissez votre sauce</p>
<ul class="composer-grid" role="list" id="sauce-grid">
${state.sauces.map(s => `
<li>
<button
class="composer-card ${state.sauce && state.sauce.id === s.id ? 'composer-card--selected' : ''}"
type="button"
data-id="${s.id}"
aria-pressed="${state.sauce && state.sauce.id === s.id ? 'true' : 'false'}"
aria-label="${escHtml(s.nom)}"
>
<img
class="composer-card__image"
src="${escHtml(s.image)}"
alt="${escHtml(s.nom)}"
onerror="this.src='assets/images/ui/logo.png';"
>
<span class="composer-card__name">${escHtml(s.nom)}</span>
</button> </button>
</li> </li>
`).join('')} `).join('')}
</ul> </ul>
`; `;
body.querySelectorAll('#slot-grid .composer-card').forEach(btn => {
body.querySelectorAll('#sauce-grid .composer-card').forEach(btn => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
const id = parseInt(btn.dataset.id, 10); const raw = btn.dataset.pid;
state.sauce = state.sauces.find(s => s.id === id) ?? state.sauce; if (raw === '') delete state.selections[slot.id];
body.querySelectorAll('#sauce-grid .composer-card').forEach(b => { else state.selections[slot.id] = parseInt(raw, 10);
const active = parseInt(b.dataset.id, 10) === state.sauce.id; body.querySelectorAll('#slot-grid .composer-card').forEach(b => {
const active = (b.dataset.pid === '' && state.selections[slot.id] == null)
|| parseInt(b.dataset.pid, 10) === state.selections[slot.id];
b.classList.toggle('composer-card--selected', active); b.classList.toggle('composer-card--selected', active);
b.setAttribute('aria-pressed', active ? 'true' : 'false'); b.setAttribute('aria-pressed', active ? 'true' : 'false');
}); });
}); });
}); });
renderFooter(footer, modal, state, { renderFooter(footer, modal, state, {
canAdvance: () => state.sauce !== null canAdvance: () => optional || state.selections[slot.id] != null,
}); });
} }
/* ------------------------------------------------------------------ */ /* Etape finale — recap + ajout */
/* Step 5 — Recap + add to cart */ function renderRecapStep(body, footer, modal, state) {
/* ------------------------------------------------------------------ */ const item = buildMenuCartItem(state.menu, state.model, {
size: state.size, selections: state.selections,
function renderStep5(body, footer, modal, state) { });
const supplement = computeSupplement(state); const total = computeMenuLineCents(item);
const baseItem = buildCartItem(state, supplement); const c = item.composition;
const totalLine = computeMenuLineCents(baseItem); const lines = [];
lines.push(`${escHtml(c.burger.libelle)}`);
const optionsText = state.burgerOptions.length if (c.accompagnement) lines.push(`${escHtml(c.accompagnement.libelle)}${c.accompagnement.taille === 'G' ? ' (Maxi)' : ''}`);
? state.burgerOptions.map(o => o === 'sans-oignon' ? 'sans oignon' : 'avec fromage').join(', ') if (c.boisson) lines.push(`${escHtml(c.boisson.libelle)}${c.boisson.taille === 'G' ? ' (Maxi)' : ''}`);
: null; if (c.sauce) lines.push(escHtml(c.sauce.libelle));
body.innerHTML = ` body.innerHTML = `
<p class="composer-step__subtitle">Recapitulatif de votre menu</p> <p class="composer-step__subtitle">Recapitulatif (${state.size === 'M' ? 'Maxi' : 'Normal'})</p>
<ul class="composer-recap" aria-label="Composition du menu"> <ul class="composer-recap" aria-label="Composition du menu">
<li class="composer-recap__line"> ${lines.map(l => `<li class="composer-recap__line"><span class="composer-recap__icon" aria-hidden="true">&#9632;</span><span class="composer-recap__label">${l}</span></li>`).join('')}
<span class="composer-recap__icon" aria-hidden="true">&#9632;</span>
<span class="composer-recap__label">
${escHtml(state.burger.nom)}
${optionsText ? `<span class="composer-recap__opts">(${escHtml(optionsText)})</span>` : ''}
</span>
</li>
<li class="composer-recap__line">
<span class="composer-recap__icon" aria-hidden="true">&#9632;</span>
<span class="composer-recap__label">
${escHtml(state.accompagnement.nom)}
<span class="composer-recap__taille">${state.accompTaille === 'G' ? 'grande' : 'normale'}</span>
${state.accompTaille === 'G' ? '<span class="composer-recap__suppl">+0,50 EUR</span>' : ''}
</span>
</li>
<li class="composer-recap__line">
<span class="composer-recap__icon" aria-hidden="true">&#9632;</span>
<span class="composer-recap__label">
${escHtml(state.boisson.nom)}
<span class="composer-recap__taille">${state.boissonTaille === 'G' ? 'grande' : 'normale'}</span>
${state.boissonTaille === 'G' ? '<span class="composer-recap__suppl">+0,50 EUR</span>' : ''}
</span>
</li>
<li class="composer-recap__line">
<span class="composer-recap__icon" aria-hidden="true">&#9632;</span>
<span class="composer-recap__label">${escHtml(state.sauce.nom)}</span>
</li>
</ul> </ul>
<div class="composer-recap__totals"> <div class="composer-recap__totals">
<span class="composer-recap__base">Menu de base : ${formatPrice(state.menu.prix_cents ?? state.menu.prix)}</span> <span class="composer-recap__total-line">Total : <strong>${formatPrice(total)}</strong></span>
${supplement > 0 ? `<span class="composer-recap__suppl-total">Supplement grande(s) taille(s) : +${formatPrice(supplement)}</span>` : ''}
<span class="composer-recap__total-line">Total : <strong>${formatPrice(totalLine)}</strong></span>
</div> </div>
`; `;
footer.innerHTML = ` footer.innerHTML = `
<div class="composer-footer__row"> <div class="composer-footer__row">
<button class="btn btn--secondary composer-footer__cancel" type="button" id="composer-cancel"> <button class="btn btn--secondary composer-footer__cancel" type="button" id="composer-cancel">Annuler</button>
Annuler <button class="btn btn--secondary composer-footer__prev" type="button" id="composer-prev">Precedent</button>
</button> <button class="btn btn--primary composer-footer__add" type="button" id="composer-add">Ajouter a ma commande</button>
<button class="btn btn--secondary composer-footer__prev" type="button" id="composer-prev">
Precedent
</button>
<button class="btn btn--primary composer-footer__add" type="button" id="composer-add">
Ajouter au panier
</button>
</div> </div>
`; `;
footer.querySelector('#composer-cancel').addEventListener('click', () => cancelComposer(modal, state.returnCategory, modal._escHandler));
footer.querySelector('#composer-cancel').addEventListener('click', () => { footer.querySelector('#composer-prev').addEventListener('click', () => { state.currentStep--; renderStep(modal, state); });
cancelComposer(modal, state.returnCategory, null);
});
footer.querySelector('#composer-prev').addEventListener('click', () => {
state.currentStep--;
renderStep(modal, state);
});
footer.querySelector('#composer-add').addEventListener('click', () => { footer.querySelector('#composer-add').addEventListener('click', () => {
addToCart(baseItem); addToCart(item);
refreshCartBadge(); refreshCartBadge();
closeComposer(modal); closeComposer(modal);
window.location.href = `products.html?category=${state.returnCategory}`; window.location.href = `products.html?category=${state.returnCategory}`;
@ -468,45 +366,22 @@ function renderStep5(body, footer, modal, state) {
} }
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* Footer renderer (steps 1-4) */ /* Footer de navigation (etapes non-recap) */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/**
* Renders the navigation footer for steps 1 through 4.
* @param {HTMLElement} footer
* @param {HTMLElement} modal
* @param {Object} state
* @param {{ canAdvance: () => boolean }} opts
*/
function renderFooter(footer, modal, state, opts) { function renderFooter(footer, modal, state, opts) {
const isFirst = state.currentStep === 1; const isFirst = state.currentStep === 0;
footer.innerHTML = ` footer.innerHTML = `
<div class="composer-footer__row"> <div class="composer-footer__row">
<button class="btn btn--secondary composer-footer__cancel" type="button" id="composer-cancel"> <button class="btn btn--secondary composer-footer__cancel" type="button" id="composer-cancel">Annuler</button>
Annuler ${!isFirst ? `<button class="btn btn--secondary composer-footer__prev" type="button" id="composer-prev">Precedent</button>` : ''}
</button> <button class="btn btn--primary composer-footer__next" type="button" id="composer-next">Suivant</button>
${!isFirst ? `
<button class="btn btn--secondary composer-footer__prev" type="button" id="composer-prev">
Precedent
</button>` : ''}
<button class="btn btn--primary composer-footer__next" type="button" id="composer-next">
Suivant
</button>
</div> </div>
`; `;
footer.querySelector('#composer-cancel').addEventListener('click', () => cancelComposer(modal, state.returnCategory, modal._escHandler));
footer.querySelector('#composer-cancel').addEventListener('click', () => {
cancelComposer(modal, state.returnCategory, null);
});
if (!isFirst) { if (!isFirst) {
footer.querySelector('#composer-prev').addEventListener('click', () => { footer.querySelector('#composer-prev').addEventListener('click', () => { state.currentStep--; renderStep(modal, state); });
state.currentStep--;
renderStep(modal, state);
});
} }
footer.querySelector('#composer-next').addEventListener('click', () => { footer.querySelector('#composer-next').addEventListener('click', () => {
if (!opts.canAdvance()) return; if (!opts.canAdvance()) return;
state.currentStep++; state.currentStep++;
@ -515,188 +390,31 @@ function renderFooter(footer, modal, state, opts) {
} }
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* Taille toggle — shared between accompagnement and boisson steps */ /* Focus trap + fermeture */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/**
* Generates the HTML for the Normale/Grande toggle.
* @param {string} prefix 'accomp' or 'boisson', used for IDs
* @param {'N'|'G'} currentTaille
* @returns {string}
*/
function renderTailleToggle(prefix, currentTaille) {
return `
<div class="composer-taille" role="group" aria-label="Taille">
<button
class="composer-taille__btn ${currentTaille === 'N' ? 'composer-taille__btn--active' : ''}"
type="button"
data-taille="N"
id="${prefix}-taille-n"
aria-pressed="${currentTaille === 'N' ? 'true' : 'false'}"
>
Normale
</button>
<button
class="composer-taille__btn ${currentTaille === 'G' ? 'composer-taille__btn--active' : ''}"
type="button"
data-taille="G"
id="${prefix}-taille-g"
aria-pressed="${currentTaille === 'G' ? 'true' : 'false'}"
>
Grande <span class="composer-taille__price-hint">+0,50 EUR</span>
</button>
</div>
`;
}
/**
* Attaches click handlers to the taille toggle buttons and keeps state in sync.
* @param {HTMLElement} body
* @param {string} prefix
* @param {Object} state
* @param {'accompTaille'|'boissonTaille'} stateKey
*/
function attachTailleToggle(body, prefix, state, stateKey) {
body.querySelectorAll('.composer-taille__btn').forEach(btn => {
btn.addEventListener('click', () => {
state[stateKey] = btn.dataset.taille;
body.querySelectorAll('.composer-taille__btn').forEach(b => {
const active = b.dataset.taille === state[stateKey];
b.classList.toggle('composer-taille__btn--active', active);
b.setAttribute('aria-pressed', active ? 'true' : 'false');
});
});
});
}
/* ------------------------------------------------------------------ */
/* Cart item assembly + supplement calculation */
/* ------------------------------------------------------------------ */
/**
* Counts how many grande-taille choices were made (0, 1, or 2).
* @param {Object} state
* @returns {number} centimes
*/
function computeSupplement(state) {
let suppl = 0;
if (state.accompTaille === 'G') suppl += SUPPLEMENT_GRANDE_CENTS;
if (state.boissonTaille === 'G') suppl += SUPPLEMENT_GRANDE_CENTS;
return suppl;
}
/**
* Builds the cart item object from the current composer state.
* prix_cents is the base menu price; supplement_cents accumulates size upgrades.
*
* @param {Object} state
* @param {number} supplement
* @returns {Object}
*/
function buildCartItem(state, supplement) {
/* Support both raw produits.json field (prix) and normalised (prix_cents) */
const prixCents = state.menu.prix_cents ?? state.menu.prix;
return {
id: state.menu.id,
type: 'menu',
categorie: 'menus',
libelle: state.menu.nom,
prix_cents: prixCents,
quantite: 1,
image: state.menu.image,
supplement_cents: supplement,
composition: {
burger: {
id: state.burger.id,
libelle: state.burger.nom,
options: [...state.burgerOptions]
},
accompagnement: {
id: state.accompagnement.id,
libelle: state.accompagnement.nom,
categorie: state.accompagnement.categorie ?? 'frites',
taille: state.accompTaille
},
boisson: {
id: state.boisson.id,
libelle: state.boisson.nom,
taille: state.boissonTaille
},
sauce: {
id: state.sauce.id,
libelle: state.sauce.nom
}
}
};
}
/* ------------------------------------------------------------------ */
/* Focus trap */
/* ------------------------------------------------------------------ */
/**
* Traps Tab / Shift+Tab inside the modal container.
* The handler is attached to the modal element itself; it is removed
* automatically when the modal is removed from the DOM.
*/
function trapFocus(modal) { function trapFocus(modal) {
modal.addEventListener('keydown', (e) => { modal.addEventListener('keydown', (e) => {
if (e.key !== 'Tab') return; if (e.key !== 'Tab') return;
const focusable = Array.from(modal.querySelectorAll('button:not([disabled]), [tabindex="0"]'))
const focusable = Array.from(modal.querySelectorAll( .filter(el => !el.closest('[hidden]'));
'button:not([disabled]), input:not([disabled]), [tabindex="0"]'
)).filter(el => !el.closest('[hidden]'));
if (!focusable.length) return; if (!focusable.length) return;
const first = focusable[0]; const first = focusable[0];
const last = focusable[focusable.length - 1]; const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
if (e.shiftKey) { else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
if (document.activeElement === first) {
e.preventDefault();
last.focus();
}
} else {
if (document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
}); });
} }
/* ------------------------------------------------------------------ */
/* Close helpers */
/* ------------------------------------------------------------------ */
function closeComposer(modal) { function closeComposer(modal) {
if (modal._escHandler) document.removeEventListener('keydown', modal._escHandler);
if (modal._bgSiblings) modal._bgSiblings.forEach(el => el.removeAttribute('aria-hidden'));
modal.remove(); modal.remove();
document.body.style.overflow = ''; document.body.style.overflow = modal._prevOverflow ?? '';
} }
function cancelComposer(modal, returnCategory, escHandler) { function cancelComposer(modal, returnCategory, escHandler) {
if (escHandler) { if (escHandler) document.removeEventListener('keydown', escHandler);
document.removeEventListener('keydown', escHandler);
}
closeComposer(modal); closeComposer(modal);
window.location.href = `products.html?category=${returnCategory}`; window.location.href = `products.html?category=${returnCategory}`;
} }
/* ------------------------------------------------------------------ */
/* Utilities */
/* ------------------------------------------------------------------ */
/**
* Minimal HTML escaping to prevent XSS when injecting product names/paths
* into innerHTML. Applied to all data-derived strings.
*/
function escHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}

View file

@ -0,0 +1,127 @@
/*
* Tests du composeur de menu slot-driven (P5 L2), node:test + jsdom.
*
* page-product-menu.js importe nav.js (qui touche le DOM au chargement) -> import
* dynamique apres pose des globals jsdom. Cible : fonctions PURES buildComposerSteps,
* buildMenuCartItem, selectionsComplete (logique slots -> etapes -> item panier).
*/
import { test, before } from 'node:test';
import assert from 'node:assert/strict';
import { JSDOM } from 'jsdom';
let buildComposerSteps, buildMenuCartItem, selectionsComplete, composerIsViable;
before(async () => {
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', { url: 'https://kiosk.test/product.html' });
global.window = dom.window;
global.document = dom.window.document;
global.localStorage = dom.window.localStorage;
({ buildComposerSteps, buildMenuCartItem, selectionsComplete, composerIsViable } =
await import('../../src/public/borne/assets/js/page-product-menu.js'));
});
const detail = () => ({
id: 1,
burger_product_id: 100,
price_normal_cents: 880,
price_maxi_cents: 1030,
slots: [
{ id: 16, name: 'Accompagnement', slot_type: 'side', is_required: true, display_order: 2, option_product_ids: [22, 23] },
{ id: 1, name: 'Boisson', slot_type: 'drink', is_required: true, display_order: 1, option_product_ids: [14, 15, 999] },
{ id: 31, name: 'Sauce', slot_type: 'sauce', is_required: false, display_order: 3, option_product_ids: [47] },
],
});
const byId = () => ({
100: { id: 100, nom: 'Le 280', prix: 0, image: 'b.png', type: 'produit' },
22: { id: 22, nom: 'Frites', prix: 0, image: 'f.png', type: 'produit' },
23: { id: 23, nom: 'Potatoes', prix: 0, image: 'p.png', type: 'produit' },
14: { id: 14, nom: 'Coca', prix: 0, image: 'c.png', type: 'produit' },
15: { id: 15, nom: 'Eau', prix: 0, image: 'e.png', type: 'produit' },
47: { id: 47, nom: 'Ketchup', prix: 0, image: 'k.png', type: 'produit' },
});
const menu = { id: 1, nom: 'Menu Le 280', image: 'b.png', type: 'menu' };
/* --- buildComposerSteps -------------------------------------------------- */
test('buildComposerSteps: burger impose resolu, slots tries par display_order', () => {
const m = buildComposerSteps(detail(), byId());
assert.equal(m.burger.nom, 'Le 280');
assert.equal(m.priceNormalCents, 880);
assert.equal(m.priceMaxiCents, 1030);
assert.deepEqual(m.slots.map(s => s.slotType), ['drink', 'side', 'sauce']); // par display_order 1,2,3
});
test('buildComposerSteps: option_product_ids resolus en produits, ids inconnus filtres', () => {
const m = buildComposerSteps(detail(), byId());
const drink = m.slots.find(s => s.slotType === 'drink');
assert.deepEqual(drink.options.map(o => o.nom), ['Coca', 'Eau']); // 999 inconnu -> filtre
assert.equal(drink.isRequired, true);
assert.equal(m.slots.find(s => s.slotType === 'sauce').isRequired, false);
});
/* --- buildMenuCartItem --------------------------------------------------- */
test('buildMenuCartItem Normal: prix normal, pas de supplement, taille N, composition mappee', () => {
const m = buildComposerSteps(detail(), byId());
const item = buildMenuCartItem(menu, m, { size: 'N', selections: { 1: 14, 16: 22, 31: 47 } });
assert.equal(item.type, 'menu');
assert.equal(item.prix_cents, 880);
assert.equal(item.supplement_cents, 0);
assert.equal(item.composition.burger.libelle, 'Le 280');
assert.deepEqual(item.composition.accompagnement, { id: 22, libelle: 'Frites', taille: 'N' });
assert.deepEqual(item.composition.boisson, { id: 14, libelle: 'Coca', taille: 'N' });
assert.deepEqual(item.composition.sauce, { id: 47, libelle: 'Ketchup' });
});
test('buildMenuCartItem Maxi: supplement = maxi - normal, taille G sur side/drink', () => {
const m = buildComposerSteps(detail(), byId());
const item = buildMenuCartItem(menu, m, { size: 'M', selections: { 1: 14, 16: 22, 31: 47 } });
assert.equal(item.prix_cents, 880);
assert.equal(item.supplement_cents, 150); // 1030 - 880
assert.equal(item.composition.accompagnement.taille, 'G');
assert.equal(item.composition.boisson.taille, 'G');
});
test('buildMenuCartItem: slot optionnel non choisi -> champ absent de composition', () => {
const m = buildComposerSteps(detail(), byId());
const item = buildMenuCartItem(menu, m, { size: 'N', selections: { 1: 14, 16: 22 } }); // pas de sauce
assert.equal(item.composition.sauce, undefined);
assert.ok(item.composition.accompagnement);
assert.ok(item.composition.boisson);
});
/* --- selectionsComplete -------------------------------------------------- */
test('selectionsComplete: vrai si tous les slots REQUIS sont choisis (sauce optionnelle ignoree)', () => {
const m = buildComposerSteps(detail(), byId());
assert.equal(selectionsComplete(m, { 1: 14, 16: 22 }), true); // requis ok, sauce absente
assert.equal(selectionsComplete(m, { 1: 14 }), false); // accompagnement requis manquant
assert.equal(selectionsComplete(m, { 1: 14, 16: 999 }), false); // id hors options du slot
});
/* --- garde-fous (findings revue L2) -------------------------------------- */
test('buildComposerSteps: ignore les slot_type hors {drink,side,sauce} (anti-perte silencieuse)', () => {
const d = detail();
d.slots.push({ id: 99, name: 'Dessert', slot_type: 'dessert', is_required: true, display_order: 4, option_product_ids: [22] });
const m = buildComposerSteps(d, byId());
assert.deepEqual(m.slots.map(s => s.slotType), ['drink', 'side', 'sauce']); // dessert exclu
});
test('composerIsViable: vrai pour un modele complet', () => {
assert.equal(composerIsViable(buildComposerSteps(detail(), byId())), true);
});
test('composerIsViable: faux si un slot requis n a aucune option resolue', () => {
const d = detail();
d.slots = [{ id: 1, name: 'Boisson', slot_type: 'drink', is_required: true, display_order: 1, option_product_ids: [999, 888] }];
assert.equal(composerIsViable(buildComposerSteps(d, byId())), false);
});
test('composerIsViable: faux si le burger impose est introuvable', () => {
const d = detail();
d.burger_product_id = 12345;
assert.equal(composerIsViable(buildComposerSteps(d, byId())), false);
});

View file

@ -165,3 +165,27 @@ test('findProduct renvoie null si l id est absent de la categorie ciblee', async
const { findProduct } = await freshData(fixtures()); const { findProduct } = await freshData(fixtures());
assert.equal(await findProduct(999, 'burgers'), null); assert.equal(await findProduct(999, 'burgers'), null);
}); });
test('loadProductsById indexe les PRODUITS par id et exclut les menus (anti-collision)', async () => {
// colliding : un produit id 4 ET un menu id 4. L'index ne doit contenir QUE le
// produit a la cle 4 (les option_product_ids des slots referencent des produits).
const colliding = {
'/api/categories': { data: [
{ id: 1, name: 'Menus', slug: 'menus', image_path: 'm.png', display_order: 1 },
{ id: 3, name: 'Burgers', slug: 'burgers', image_path: 'b.png', display_order: 3 },
] },
'/api/products': { data: [
{ id: 4, category_id: 3, name: 'Big Mac', description: null, price_cents: 600, image_path: 'bigmac.png', display_order: 4 },
{ id: 14, category_id: 3, name: 'Coca', description: null, price_cents: 190, image_path: 'coca.png', display_order: 1 },
] },
'/api/menus': { data: [
{ id: 4, category_id: 1, burger_product_id: 4, name: 'Menu Big Mac', description: null, price_normal_cents: 800, price_maxi_cents: 950, image_path: 'bigmac.png', display_order: 1 },
] },
};
const { loadProductsById } = await freshData(colliding);
const byId = await loadProductsById();
assert.equal(byId[4].type, 'produit', 'id 4 doit etre le PRODUIT, pas le menu');
assert.equal(byId[4].nom, 'Big Mac');
assert.equal(byId[14].nom, 'Coca');
assert.equal(Object.keys(byId).length, 2, 'que les 2 produits, le menu est exclu');
});