feat(borne): panneau commande persistant + bandeau categories (P5 L1)
All checks were successful
CI / secret-scan (pull_request) Successful in 9s
CI / php-lint (pull_request) Successful in 23s
CI / static-tests (pull_request) Successful in 50s
CI / secret-scan (push) Successful in 9s
CI / js-tests (pull_request) Successful in 29s
CI / php-lint (push) Successful in 20s
CI / static-tests (push) Successful in 50s
CI / js-tests (push) Successful in 28s

Realigne les ecrans de commande sur la maquette (docs/design/maquette-vs-build.md) :
mono-ecran hybride avec recap persistant a droite + bandeau categories en haut.

- order-panel.js : panneau commande persistant (vue-modele pure buildPanelModel +
  rendu) sur products.html/product.html ; lignes du panier localStorage avec options
  de menu, total ttc, Abandon (retour accueil) / Payer ; retrait par ligne. aria-live.
- category-strip.js : bandeau categories horizontal (fleches), categorie active
  resolue par id (donnees API live), surlignee charte ; navigation par categorie.
- charte : layout 2 colonnes, carte panneau + cartes bandeau (tokens existants),
  indicateur actif renforce (bordure foncee + fond teinte, a11y 1.4.11), focus-visible.
- tests : tests/js/order-panel.test.js (11) + category-strip.test.js (7), node:test +
  jsdom (fonctions pures + rendu + anti-XSS RG-T15). 38/38 verts.

Revue adversariale (workflow) : 8 findings, must-fix integres (actif par id, contraste,
focus, aria-live, id echappe, logo). Differes hors lot : memoisation des loaders data.js,
strip sur product.html (-> L3 modale).
This commit is contained in:
Imugiii 2026-06-19 14:53:12 +00:00
parent f2fdaea89a
commit 0bb0048c64
7 changed files with 804 additions and 0 deletions

View file

@ -1809,3 +1809,275 @@ button {
margin-right: 0;
}
}
/* === Panneau de commande persistant (L1 - maquette : recap a droite) =======
Layout deux colonnes sur les ecrans de commande : contenu a gauche, panneau
a droite, sticky pleine hauteur. Le panneau = entete (titre + mode), corps
scrollable (lignes du panier), pied (total + Abandon/Payer). Reutilise les
tokens de :root pour rester aligne sur la charte. */
.order-layout {
display: flex;
align-items: flex-start;
gap: var(--space-5);
padding: 0 var(--space-5) var(--space-5);
}
.order-layout > main {
flex: 1 1 auto;
min-width: 0; /* laisse la grille produits se retrecir au lieu de deborder */
}
.order-panel {
flex: 0 0 360px;
width: 360px;
position: sticky;
top: var(--space-4);
max-height: calc(100vh - var(--space-6));
display: flex;
flex-direction: column;
background: var(--color-bg-card);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
overflow: hidden;
}
.order-panel__head {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-4) var(--space-5);
border-bottom: 1px solid var(--color-border-default);
}
.order-panel__logo {
height: 22px;
width: auto;
}
.order-panel__title {
font-size: var(--font-size-md);
font-weight: var(--font-weight-bold);
}
.order-panel__mode {
margin-left: auto;
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
background: var(--color-bg-page);
border-radius: var(--radius-pill);
padding: var(--space-1) var(--space-3);
}
.order-panel__body {
flex: 1 1 auto;
overflow-y: auto;
padding: var(--space-3) var(--space-4);
}
.order-panel__empty {
color: var(--color-text-muted);
text-align: center;
padding: var(--space-6) var(--space-3);
line-height: 1.5;
}
.order-panel__lines {
list-style: none;
margin: 0;
padding: 0;
}
.order-panel__line {
position: relative;
padding: var(--space-3) var(--space-6) var(--space-3) 0;
border-bottom: 1px solid var(--color-border-default);
}
.order-panel__line:last-child {
border-bottom: none;
}
.order-panel__line-main {
display: flex;
justify-content: space-between;
gap: var(--space-2);
font-weight: var(--font-weight-bold);
}
.order-panel__line-price {
white-space: nowrap;
}
.order-panel__options {
list-style: none;
margin: var(--space-1) 0 0;
padding: 0;
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
}
.order-panel__options li::before {
content: "+ ";
}
.order-panel__remove {
position: absolute;
top: var(--space-3);
right: 0;
border: none;
background: none;
cursor: pointer;
padding: var(--space-1);
line-height: 0;
}
.order-panel__foot {
border-top: 1px solid var(--color-border-default);
padding: var(--space-4) var(--space-5);
}
.order-panel__total {
display: flex;
justify-content: space-between;
align-items: baseline;
font-size: var(--font-size-md);
margin-bottom: var(--space-4);
}
.order-panel__total-value {
font-size: var(--font-size-lg);
font-weight: var(--font-weight-bold);
}
.order-panel__actions {
display: flex;
gap: var(--space-3);
}
.order-panel__abandon,
.order-panel__pay {
flex: 1 1 0;
text-align: center;
border-radius: var(--radius-pill);
padding: var(--space-3) var(--space-4);
font-weight: var(--font-weight-bold);
font-size: var(--font-size-base);
cursor: pointer;
text-decoration: none;
}
.order-panel__abandon {
background: var(--color-bg-card);
border: 1px solid var(--color-border-default);
color: var(--color-text-secondary);
}
.order-panel__pay {
background: var(--color-brand-yellow);
border: 1px solid var(--color-brand-yellow);
color: var(--color-text-primary);
}
.order-panel__pay[aria-disabled="true"] {
opacity: 0.5;
pointer-events: none;
}
/* Ecran etroit : le panneau passe sous le contenu (le kiosk reste large en
pratique ; repli de surete pour les petits viewports). */
@media (max-width: 900px) {
.order-layout {
flex-direction: column;
}
.order-panel {
width: auto;
flex-basis: auto;
position: static;
max-height: none;
align-self: stretch;
}
}
/* === Bandeau categories (L1 - maquette : strip horizontal en haut) =========
Cartes categorie defilantes avec fleches rouges ; la categorie courante porte
une bordure jaune (charte maquette). Sticky en haut du contenu de commande. */
.category-strip {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-4) 0;
position: sticky;
top: 0;
background: var(--color-bg-page);
z-index: 5;
}
.category-strip__scroller {
display: flex;
gap: var(--space-3);
overflow-x: auto;
scroll-behavior: smooth;
flex: 1 1 auto;
scrollbar-width: none; /* Firefox : masque la scrollbar, on navigue aux fleches */
}
.category-strip__scroller::-webkit-scrollbar {
display: none;
}
.category-strip__item {
flex: 0 0 auto;
width: 110px;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-1);
padding: var(--space-2);
background: var(--color-bg-card);
border: 2px solid transparent;
border-radius: var(--radius-md);
box-shadow: var(--shadow-card);
text-decoration: none;
color: var(--color-text-primary);
font-size: var(--font-size-sm);
text-align: center;
}
.category-strip__item.is-active {
border-color: var(--color-brand-yellow-dk);
border-width: 3px;
background: #FFF8E6; /* 2e cue (pas que la couleur de bordure) -- a11y 1.4.11 */
}
.category-strip__img {
width: 56px;
height: 56px;
object-fit: contain;
}
.category-strip__label {
font-weight: var(--font-weight-bold);
}
.category-strip__arrow {
flex: 0 0 auto;
border: none;
background: none;
color: var(--color-brand-red);
font-size: var(--font-size-xl);
line-height: 1;
padding: var(--space-2);
cursor: pointer;
}
/* Focus clavier visible sur les controles L1 (panneau + bandeau). Outline jaune
fonce decale pour rester percevable par-dessus la charte. */
.order-panel__pay:focus-visible,
.order-panel__abandon:focus-visible,
.order-panel__remove:focus-visible,
.category-strip__item:focus-visible,
.category-strip__arrow:focus-visible {
outline: 3px solid var(--color-brand-yellow-dk);
outline-offset: 2px;
}

View file

@ -0,0 +1,104 @@
/*
* category-strip.js Bandeau categories horizontal (maquette : strip en haut de
* l'ecran de commande, avec fleches et la categorie courante surlignee).
*
* Permet de changer de categorie sans repasser par categories.html. Les categories
* viennent de /api/categories (via loadCategories de data.js : seules les categories
* actives/commandables). La logique est separee en : buildStripModel (PUR, testable)
* + renderStripInto (DOM sans fetch, testable jsdom) + renderCategoryStrip (lit l'URL,
* fetch, monte) pour l'auto-montage.
*/
import { loadCategories } from './data.js';
import { escHtml } from './state.js';
/**
* Vue-modele PUR : marque la categorie active. Aucune dependance DOM/fetch.
* L'actif est resolu par ID (donnees live de l'API), pas via une table id->slug
* codee en dur, pour rester aligne sur le catalogue reel.
* @param {Array<{id:number,title:string,slug:string,image:string}>} categories
* @param {number} activeId
* @returns {Array}
*/
export function buildStripModel(categories, activeId) {
return categories.map(c => ({
id: Number(c.id),
slug: c.slug,
title: c.title,
image: c.image,
active: Number(c.id) === activeId,
}));
}
/**
* Capitalise la 1re lettre (titre de categorie affiche).
* @param {string} s
* @returns {string}
*/
function cap(s) {
return s.charAt(0).toUpperCase() + s.slice(1);
}
/**
* Rend le bandeau dans le conteneur a partir d'un modele deja construit. Pas de
* fetch : c'est la cible des tests jsdom. Chaque carte navigue vers la categorie
* (en preservant le mode). Les fleches font defiler le scroller horizontalement.
* @param {HTMLElement} container
* @param {Array} model sortie de buildStripModel
* @param {string|null} modeParam mode de consommation a propager dans l'URL
*/
export function renderStripInto(container, model, modeParam) {
if (!container) return;
const modeQS = modeParam ? `&mode=${encodeURIComponent(modeParam)}` : '';
const cards = model.map(c => `
<a
class="category-strip__item${c.active ? ' is-active' : ''}"
href="products.html?category=${c.id}${modeQS}"
aria-label="${escHtml(cap(c.title))}"
${c.active ? 'aria-current="true"' : ''}
>
<img class="category-strip__img" src="${escHtml(c.image)}" alt="" aria-hidden="true"
onerror="this.style.visibility='hidden';">
<span class="category-strip__label">${escHtml(cap(c.title))}</span>
</a>
`).join('');
container.innerHTML = `
<button class="category-strip__arrow category-strip__arrow--prev" type="button" aria-label="Categories precedentes">&#9664;</button>
<div class="category-strip__scroller">${cards}</div>
<button class="category-strip__arrow category-strip__arrow--next" type="button" aria-label="Categories suivantes">&#9654;</button>
`;
const scroller = container.querySelector('.category-strip__scroller');
const step = 320;
const prev = container.querySelector('.category-strip__arrow--prev');
const next = container.querySelector('.category-strip__arrow--next');
// scrollBy/scrollIntoView absents de jsdom -> gardes pour ne pas jeter en test.
if (prev) prev.addEventListener('click', () => scroller.scrollBy?.({ left: -step, behavior: 'smooth' }));
if (next) next.addEventListener('click', () => scroller.scrollBy?.({ left: step, behavior: 'smooth' }));
const active = scroller.querySelector('.is-active');
active?.scrollIntoView?.({ inline: 'center', block: 'nearest' });
}
/**
* Monte le bandeau : lit ?category=&mode= dans l'URL, charge les categories,
* construit le modele et rend. Tolerant a un echec de chargement (ne casse pas la page).
* @param {HTMLElement} container
*/
export async function renderCategoryStrip(container) {
if (!container) return;
const params = new URLSearchParams(window.location.search);
const activeId = parseInt(params.get('category'), 10) || 1;
const modeParam = params.get('mode');
try {
const categories = await loadCategories();
renderStripInto(container, buildStripModel(categories, activeId), modeParam);
} catch (e) {
console.error('renderCategoryStrip error:', e);
}
}
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('[data-category-strip]').forEach(renderCategoryStrip);
});

View file

@ -0,0 +1,193 @@
/*
* order-panel.js Panneau de commande persistant (maquette : recap a droite de
* l'ecran de commande). Rendu sur les ecrans de commande (products, product) pour
* que le panier reste visible en permanence, comme sur la maquette borne.
*
* C'est un miroir COMPACT de page-cart.js : meme modele d'item, meme rendu de la
* composition de menu. La page panier (cart.html) reste la vue detaillee (TVA, +/-) ;
* le panneau, lui, montre lignes + total + Abandon/Payer et permet de retirer une
* ligne. La logique de mise en forme est extraite en fonctions PURES (buildPanelModel,
* compositionLabels) pour etre testable sans DOM.
*/
import {
getCart,
removeFromCart,
computeMenuLineCents,
clearCart,
formatPrice,
escHtml,
getMode,
} from './state.js';
import { refreshCartBadge } from './nav.js';
/**
* Calcule le total d'une ligne en centimes (menu : avec supplement de taille ;
* produit simple : prix * quantite). Pur.
* @param {Object} item
* @returns {number}
*/
export function lineCents(item) {
return item.type === 'menu'
? computeMenuLineCents(item)
: item.prix_cents * item.quantite;
}
/**
* Construit les libelles des options d'un menu (puces sous le nom de ligne).
* Miroir de renderCompositionBlock() de page-cart.js, sans le supplement (le panneau
* affiche le total de ligne, pas le detail TVA). Tolerant aux composants absents.
* @param {Object|undefined} c objet composition de l'item menu
* @returns {string[]}
*/
export function compositionLabels(c) {
if (!c) return [];
const out = [];
if (c.burger) {
const opts = c.burger.options && c.burger.options.length
? ` (${c.burger.options.map(o => o === 'sans-oignon' ? 'sans oignon' : 'avec fromage').join(', ')})`
: '';
out.push(`${c.burger.libelle}${opts}`);
}
if (c.accompagnement) {
out.push(`${c.accompagnement.libelle}${c.accompagnement.taille === 'G' ? ' grande' : ''}`);
}
if (c.boisson) {
out.push(`${c.boisson.libelle}${c.boisson.taille === 'G' ? ' grande' : ''}`);
}
if (c.sauce) {
out.push(c.sauce.libelle);
}
return out;
}
/**
* Vue-modele PUR du panneau a partir d'un panier. Aucune dependance DOM/localStorage :
* c'est la cible des tests unitaires.
* @param {Array} cart
* @returns {{lines: Array, totalCents: number, count: number, empty: boolean}}
*/
export function buildPanelModel(cart) {
const lines = cart.map((item, index) => ({
index,
libelle: item.libelle,
quantite: item.quantite,
lineCents: lineCents(item),
options: item.type === 'menu' ? compositionLabels(item.composition) : [],
}));
const totalCents = cart.reduce((sum, item) => sum + lineCents(item), 0);
const count = cart.reduce((sum, item) => sum + item.quantite, 0);
return { lines, totalCents, count, empty: cart.length === 0 };
}
/**
* Libelle lisible du mode de consommation pour l'en-tete du panneau.
* @returns {string}
*/
function modeLabel() {
return getMode() === 'a-emporter' ? 'A emporter' : 'Sur place';
}
/**
* Construit le HTML d'une ligne du panneau. Toute valeur derivee du catalogue est
* echappee (RG-T15 anti-XSS), comme dans page-cart.js.
* @param {Object} line element de buildPanelModel().lines
* @returns {string}
*/
function lineHtml(line) {
const options = line.options.length
? `<ul class="order-panel__options">${line.options
.map(o => `<li>${escHtml(o)}</li>`)
.join('')}</ul>`
: '';
return `
<li class="order-panel__line">
<div class="order-panel__line-main">
<span class="order-panel__line-name">${line.quantite}&times; ${escHtml(line.libelle)}</span>
<span class="order-panel__line-price">${formatPrice(line.lineCents)}</span>
</div>
${options}
<button
class="order-panel__remove"
data-index="${line.index}"
type="button"
aria-label="Retirer ${escHtml(line.libelle)} de la commande"
>
<img src="assets/images/ui/trash.png" alt="" aria-hidden="true" width="20" height="20">
</button>
</li>
`;
}
/**
* Rend le panneau dans le conteneur fourni et cable les interactions (retrait de
* ligne, Abandon, Payer). Lit le panier courant via getCart(). Re-rend apres chaque
* mutation pour rester synchrone avec localStorage.
*
* Abandon = annuler toute la commande -> retour accueil (index.html), semantique borne.
* Payer = aller a la page de paiement ; desactive (aria-disabled) panier vide.
*
* @param {HTMLElement} container l'element [data-order-panel]
*/
export function renderOrderPanel(container) {
if (!container) return;
const model = buildPanelModel(getCart());
refreshCartBadge();
const body = model.empty
? '<p class="order-panel__empty">Votre commande est vide.<br>Ajoutez un produit pour commencer.</p>'
: `<ul class="order-panel__lines">${model.lines.map(lineHtml).join('')}</ul>`;
container.innerHTML = `
<div class="order-panel__head">
<img class="order-panel__logo" src="assets/images/ui/logo.png" alt="Wakdo">
<span class="order-panel__title">Ma commande</span>
<span class="order-panel__mode">${escHtml(modeLabel())}</span>
</div>
<div class="order-panel__body">${body}</div>
<div class="order-panel__foot">
<div class="order-panel__total">
<span>TOTAL (ttc)</span>
<span class="order-panel__total-value">${formatPrice(model.totalCents)}</span>
</div>
<div class="order-panel__actions">
<button class="order-panel__abandon" type="button">Abandon</button>
<a
class="order-panel__pay"
href="payment.html"
role="button"
aria-disabled="${model.empty ? 'true' : 'false'}"
>Payer</a>
</div>
</div>
`;
container.querySelectorAll('.order-panel__remove').forEach(btn => {
btn.addEventListener('click', () => {
removeFromCart(parseInt(btn.dataset.index, 10));
renderOrderPanel(container);
});
});
const abandon = container.querySelector('.order-panel__abandon');
if (abandon) {
abandon.addEventListener('click', () => {
clearCart();
window.location.href = 'index.html';
});
}
// Payer desactive sur panier vide : un <a> ignore `disabled`, on bloque le clic
// via aria-disabled (meme parade que page-cart.js / le fix a11y E2E #45).
const pay = container.querySelector('.order-panel__pay');
if (pay) {
pay.addEventListener('click', e => {
if (pay.getAttribute('aria-disabled') === 'true') e.preventDefault();
});
}
}
/* Auto-montage sur les ecrans qui exposent un conteneur [data-order-panel]. */
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('[data-order-panel]').forEach(renderOrderPanel);
});

View file

@ -43,6 +43,7 @@
</a>
</header>
<div class="order-layout">
<main class="product-main" aria-label="Detail du produit">
<!-- Error block: hidden unless fetch fails or id invalid -->
@ -59,7 +60,11 @@
</main>
<aside class="order-panel" data-order-panel aria-label="Ma commande" aria-live="polite"></aside>
</div>
<script type="module" src="assets/js/nav.js"></script>
<script type="module" src="assets/js/page-product.js"></script>
<script type="module" src="assets/js/order-panel.js"></script>
</body>
</html>

View file

@ -42,8 +42,11 @@
</a>
</header>
<div class="order-layout">
<main class="products-main" aria-label="Liste des produits">
<nav class="category-strip" data-category-strip aria-label="Categories"></nav>
<div class="products-header">
<!--
Heading is updated by page-products.js once the category
@ -62,7 +65,12 @@
</main>
<aside class="order-panel" data-order-panel aria-label="Ma commande" aria-live="polite"></aside>
</div>
<script type="module" src="assets/js/nav.js"></script>
<script type="module" src="assets/js/page-products.js"></script>
<script type="module" src="assets/js/category-strip.js"></script>
<script type="module" src="assets/js/order-panel.js"></script>
</body>
</html>

View file

@ -0,0 +1,82 @@
/*
* Tests du bandeau categories (node:test + jsdom).
*
* Import dynamique apres pose des globals jsdom (le module enregistre un
* DOMContentLoaded au chargement). Cible : buildStripModel (PUR) + renderStripInto
* (DOM sans fetch).
*/
import { test, before } from 'node:test';
import assert from 'node:assert/strict';
import { JSDOM } from 'jsdom';
let buildStripModel, renderStripInto;
before(async () => {
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
url: 'https://kiosk.test/products.html?category=3&mode=sur-place',
});
global.window = dom.window;
global.document = dom.window.document;
global.localStorage = dom.window.localStorage;
({ buildStripModel, renderStripInto } =
await import('../../src/public/borne/assets/js/category-strip.js'));
});
const cats = () => ([
{ id: 1, title: 'menus', slug: 'menus', image: 'cat/menus.png' },
{ id: 3, title: 'burgers', slug: 'burgers', image: 'cat/burgers.png' },
{ id: 2, title: 'boissons', slug: 'boissons', image: 'cat/boissons.png' },
]);
/* --- buildStripModel (pur) ----------------------------------------------- */
test('buildStripModel: marque active par id, preserve les champs', () => {
const m = buildStripModel(cats(), 3);
assert.equal(m.length, 3);
assert.deepEqual(m[1], { id: 3, slug: 'burgers', title: 'burgers', image: 'cat/burgers.png', active: true });
assert.equal(m[0].active, false);
assert.equal(m[2].active, false);
});
test('buildStripModel: aucun id correspondant -> tout inactif', () => {
assert.equal(buildStripModel(cats(), 999).filter(c => c.active).length, 0);
});
/* --- renderStripInto (jsdom) --------------------------------------------- */
test('renderStripInto: une carte par categorie + 2 fleches', () => {
const el = document.createElement('nav');
renderStripInto(el, buildStripModel(cats(), 3), 'sur-place');
assert.equal(el.querySelectorAll('.category-strip__item').length, 3);
assert.ok(el.querySelector('.category-strip__arrow--prev'));
assert.ok(el.querySelector('.category-strip__arrow--next'));
});
test('renderStripInto: la categorie active porte is-active + aria-current', () => {
const el = document.createElement('nav');
renderStripInto(el, buildStripModel(cats(), 3), null);
const active = el.querySelectorAll('.category-strip__item.is-active');
assert.equal(active.length, 1);
assert.equal(active[0].getAttribute('aria-current'), 'true');
assert.match(active[0].getAttribute('aria-label'), /Burgers/);
});
test('renderStripInto: href = categorie par id, mode propage', () => {
const el = document.createElement('nav');
renderStripInto(el, buildStripModel(cats(), 1), 'a-emporter');
const first = el.querySelector('.category-strip__item');
assert.match(first.getAttribute('href'), /products\.html\?category=1&mode=a-emporter/);
});
test('renderStripInto: sans mode, pas de &mode dans l href', () => {
const el = document.createElement('nav');
renderStripInto(el, buildStripModel(cats(), 1), null);
assert.doesNotMatch(el.querySelector('.category-strip__item').getAttribute('href'), /mode=/);
});
test('renderStripInto: titre echappe (anti-XSS)', () => {
const el = document.createElement('nav');
renderStripInto(el, buildStripModel([{ id: 9, title: '<b>x</b>', slug: 'x', image: 'i.png' }], 9), null);
assert.match(el.innerHTML, /&lt;b&gt;/);
assert.equal(el.querySelectorAll('b').length, 0);
});

View file

@ -0,0 +1,140 @@
/*
* Tests du panneau de commande persistant (node:test + jsdom).
*
* order-panel.js touche le DOM au chargement (auto-montage DOMContentLoaded) et
* importe nav.js (idem) -> import DYNAMIQUE apres avoir pose les globals jsdom,
* sinon le module jette a l'import en environnement Node nu.
*
* Cible principale : les fonctions PURES (lineCents, compositionLabels,
* buildPanelModel). Plus un test de rendu via jsdom (etat vide, lignes, Payer
* desactive panier vide, retrait de ligne).
*/
import { test, before, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { JSDOM } from 'jsdom';
let lineCents, compositionLabels, buildPanelModel, renderOrderPanel;
before(async () => {
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
url: 'https://kiosk.test/products.html', // une origine active localStorage
});
global.window = dom.window;
global.document = dom.window.document;
global.localStorage = dom.window.localStorage;
({ lineCents, compositionLabels, buildPanelModel, renderOrderPanel } =
await import('../../src/public/borne/assets/js/order-panel.js'));
});
beforeEach(() => {
global.localStorage.clear();
});
const simple = (over = {}) => ({
id: 3, type: 'produit', categorie: 'burgers', libelle: 'Big Tasty',
prix_cents: 890, quantite: 1, image: 'x.png', ...over,
});
const menu = (over = {}) => ({
id: 1, type: 'menu', libelle: 'Menu Best Of', prix_cents: 750, quantite: 1,
supplement_cents: 50, image: 'm.png',
composition: {
burger: { libelle: 'Big Mac', options: ['sans-oignon', 'avec-fromage'] },
accompagnement: { libelle: 'Frites', taille: 'G' },
boisson: { libelle: 'Coca', taille: 'M' },
sauce: { libelle: 'Ketchup' },
},
...over,
});
/* --- lineCents (pur) ----------------------------------------------------- */
test('lineCents: produit simple = prix * quantite', () => {
assert.equal(lineCents(simple({ prix_cents: 890, quantite: 3 })), 2670);
});
test('lineCents: menu = (prix + supplement) * quantite', () => {
assert.equal(lineCents(menu({ prix_cents: 750, supplement_cents: 50, quantite: 2 })), 1600);
});
/* --- compositionLabels (pur) --------------------------------------------- */
test('compositionLabels: undefined -> []', () => {
assert.deepEqual(compositionLabels(undefined), []);
});
test('compositionLabels: liste burger(options)/accompagnement(taille)/boisson/sauce', () => {
const labels = compositionLabels(menu().composition);
assert.deepEqual(labels, [
'Big Mac (sans oignon, avec fromage)',
'Frites grande',
'Coca',
'Ketchup',
]);
});
test('compositionLabels: composants absents ignores sans jeter', () => {
assert.deepEqual(compositionLabels({ sauce: { libelle: 'Mayo' } }), ['Mayo']);
});
/* --- buildPanelModel (pur) ----------------------------------------------- */
test('buildPanelModel: panier vide', () => {
const m = buildPanelModel([]);
assert.equal(m.empty, true);
assert.equal(m.totalCents, 0);
assert.equal(m.count, 0);
assert.deepEqual(m.lines, []);
});
test('buildPanelModel: total = somme des lignes, count = somme des quantites', () => {
const m = buildPanelModel([
simple({ prix_cents: 890, quantite: 2 }), // 1780
menu({ prix_cents: 750, supplement_cents: 50, quantite: 1 }), // 800
]);
assert.equal(m.empty, false);
assert.equal(m.totalCents, 2580);
assert.equal(m.count, 3);
assert.equal(m.lines.length, 2);
assert.equal(m.lines[0].lineCents, 1780);
assert.equal(m.lines[0].options.length, 0); // produit simple : pas d'options
assert.equal(m.lines[1].lineCents, 800);
assert.equal(m.lines[1].options.length, 4); // menu : 4 puces
assert.equal(m.lines[1].index, 1); // index preserve pour le retrait
});
/* --- renderOrderPanel (jsdom) -------------------------------------------- */
test('renderOrderPanel: panier vide -> message + Payer desactive', () => {
const el = document.createElement('aside');
renderOrderPanel(el);
assert.match(el.innerHTML, /vide/i);
assert.equal(el.querySelector('.order-panel__pay').getAttribute('aria-disabled'), 'true');
});
test('renderOrderPanel: lignes rendues + Payer actif + total affiche', () => {
localStorage.setItem('wakdo_cart', JSON.stringify([simple({ prix_cents: 890, quantite: 2 })]));
const el = document.createElement('aside');
renderOrderPanel(el);
assert.equal(el.querySelectorAll('.order-panel__line').length, 1);
assert.equal(el.querySelector('.order-panel__pay').getAttribute('aria-disabled'), 'false');
assert.match(el.querySelector('.order-panel__total-value').textContent, /17,80/);
});
test('renderOrderPanel: clic corbeille retire la ligne et re-rend', () => {
localStorage.setItem('wakdo_cart', JSON.stringify([simple(), menu()]));
const el = document.createElement('aside');
renderOrderPanel(el);
assert.equal(el.querySelectorAll('.order-panel__line').length, 2);
el.querySelector('.order-panel__remove').click(); // retire la 1re ligne
assert.equal(el.querySelectorAll('.order-panel__line').length, 1);
assert.equal(JSON.parse(localStorage.getItem('wakdo_cart')).length, 1);
});
test('renderOrderPanel: libelle de ligne echappe (anti-XSS RG-T15)', () => {
localStorage.setItem('wakdo_cart', JSON.stringify([simple({ libelle: '<img src=x onerror=alert(1)>' })]));
const el = document.createElement('aside');
renderOrderPanel(el);
assert.match(el.innerHTML, /&lt;img/);
assert.equal(el.querySelectorAll('img[onerror]').length, 0);
});