corentin_wakdo/tests/js/counter-order.test.js
Imugiii eed2daffb0
All checks were successful
CI / secret-scan (push) Successful in 16s
CI / secret-scan (pull_request) Successful in 17s
CI / php-lint (pull_request) Successful in 40s
CI / php-lint (push) Successful in 41s
CI / static-tests (push) Successful in 1m14s
CI / js-tests (push) Successful in 46s
CI / static-tests (pull_request) Successful in 1m15s
CI / js-tests (pull_request) Successful in 42s
feat(back-office): refonte saisie commande comptoir/drive (prix, verrou, nav, file)
7 ameliorations de la page de saisie comptoir/drive, inutilisable au quotidien :
- prix par ligne + total + bouton "Encaisser X,XX EUR" (l'equipier encaissait
  sans voir aucun montant) ;
- verrou drive reel (affichage fige + input hidden ; readonly sur un select est
  sans effet HTML) ;
- lien de nav "Saisie commande" route selon le canal du role (un equipier drive
  atterrissait au comptoir) ;
- champ quantite desactive pour un produit personnalisable (sa saisie etait
  ignoree en silence) ;
- file "En cours" (commandes payees du canal, plus ancienne d'abord) au-dessus
  de l'historique ;
- feedback prix Normal/Maxi dans la liste et le total de ligne ;
- numero de table (dine_in comptoir), groupage par categorie, modale a overlay
  + fermeture Echap + message requis inline.

Serveur autoritatif inchange (les prix cote client sont indicatifs).
availableForCatalogue expose category_name et trie par categorie ; la borne
regroupe deja par categorie (ordre intra-categorie preserve) donc son rendu ne
bouge pas. Tests : JS 104, PHP unit 398, PHPStan L6.
2026-06-24 09:59:18 +00:00

502 lines
22 KiB
JavaScript

/*
* Tests du composeur de commande comptoir/drive (counter-order.js, sous-lot 3c).
* node:test + jsdom. Couvre la serialisation du panier dans #items_json :
* - ajout produit (champ quantite) -> item {type:'product', ...}
* - personnalisation produit (retrait + ajout d'ingredients) -> modifiers:[...]
* - configuration menu (slots + format Maxi + modificateurs burger) -> item menu
* - menu non configurable (slot_type non gere) ignore (anti-perte silencieuse)
*
* Le serveur revalide la forme (RG-T18), revalide chaque modificateur (resolveModifiers)
* et recalcule les prix (RG-T16) : on n'asserte que la FORME emise. Le prix affiche
* cote client (total + libelle du bouton) est INDICATIF : on verrouille seulement
* l'affichage local (somme price + surcouts), pas une verite metier serveur.
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { JSDOM } from 'jsdom';
// counter-order.js est du CommonJS (admin = racine CommonJS) ; import par defaut.
import counterOrder from '../../src/public/admin/assets/js/counter-order.js';
const PRODUCTS = [
{
id: 12, name: 'Cheeseburger', price: 890,
modifiers: [
{ ingredient_id: 3, name: 'Oignon', is_removable: 1, is_addable: 0, extra_price_cents: 0 },
{ ingredient_id: 8, name: 'Bacon', is_removable: 0, is_addable: 1, extra_price_cents: 50 },
],
},
{ id: 22, name: 'Frites', price: 250, modifiers: [] },
{ id: 14, name: 'Coca', price: 200, modifiers: [] },
{ id: 47, name: 'Ketchup', price: 0, modifiers: [] },
];
const MENUS = [
{
id: 5,
name: 'Menu Cheeseburger',
price_normal: 990,
price_maxi: 1190,
burger_modifiers: [
{ ingredient_id: 3, name: 'Oignon', is_removable: 1, is_addable: 0, extra_price_cents: 0 },
{ ingredient_id: 8, name: 'Bacon', is_removable: 0, is_addable: 1, extra_price_cents: 50 },
],
slots: [
{ id: 16, name: 'Accompagnement', slot_type: 'side', is_required: 1, display_order: 2, option_product_ids: [22] },
{ id: 1, name: 'Boisson', slot_type: 'drink', is_required: 1, display_order: 1, option_product_ids: [14] },
{ id: 31, name: 'Sauce', slot_type: 'sauce', is_required: 0, display_order: 3, option_product_ids: [47] },
],
},
];
function setup(menus = MENUS) {
const menuItems = menus
.map(m => `<li><button class="menu-configure" type="button" data-menu-id="${m.id}">Configurer</button></li>`)
.join('');
// qty_<id> pour tous les produits (repli sans JS) ; bouton "Personnaliser" pour
// ceux dont la recette offre des modificateurs (calque la vue new.php). Progressive
// enhancement (etape 4) : le champ qty est rendu EDITABLE en HTML pour TOUS les
// produits ; c'est le JS qui neutralise le champ d'un produit configurable au cablage
// et revele l'indice "via Personnaliser" (span data-qty-hint, hidden en HTML).
const productRows = PRODUCTS
.map(p => {
const hasMods = p.modifiers && p.modifiers.length;
const configure = hasMods
? `<button class="product-configure" type="button" data-product-id="${p.id}">Personnaliser</button>`
: '';
const hint = hasMods
? `<span class="order-qty-hint" data-qty-hint="${p.id}" hidden>via Personnaliser</span>`
: '';
return `<input class="order-qty" type="number" id="qty_${p.id}" name="qty_${p.id}" data-product-id="${p.id}" value="0">${hint}${configure}`;
})
.join('');
const dom = new JSDOM(
'<!DOCTYPE html><html><body>' +
'<form id="counter-order-form" method="post" action="/counter/orders" ' +
` data-products='${JSON.stringify(PRODUCTS)}' data-menus='${JSON.stringify(menus)}'>` +
' <input type="hidden" name="items_json" id="items_json" value="">' +
' <select id="service_mode" name="service_mode"><option value="dine_in" selected>Sur place</option><option value="takeaway">A emporter</option></select>' +
' <div id="service_tag_group"><input type="text" id="service_tag" name="service_tag"></div>' +
productRows +
' <ul id="menu-list">' + menuItems + '</ul>' +
' <ul id="order-cart"><li id="order-cart-empty">Panier vide.</li></ul>' +
' <p id="order-total">Total : <span id="order-total-value">0,00 EUR</span></p>' +
' <button type="submit" id="order-submit">Encaisser 0,00 EUR</button>' +
'</form>' +
'<div id="menu-composer-modal" hidden></div>' +
'</body></html>',
);
return dom;
}
function fireSubmit(dom) {
const form = dom.window.document.getElementById('counter-order-form');
form.dispatchEvent(new dom.window.Event('submit', { cancelable: true, bubbles: true }));
}
function itemsJson(dom) {
return JSON.parse(dom.window.document.getElementById('items_json').value || '[]');
}
test('ajout produit sans modificateur (quantite) -> items_json contient {type:product}', () => {
const dom = setup();
counterOrder.init(dom.window.document);
// Frites (22) n'a pas de modificateur -> pas de bouton Personnaliser -> chemin qty_<id>.
const qty = dom.window.document.getElementById('qty_22');
qty.value = '2';
fireSubmit(dom);
const items = itemsJson(dom);
assert.deepEqual(items, [{ type: 'product', product_id: 22, quantity: 2 }]);
});
test('produit personnalisable : qty directe ignoree (route par la modale, anti-double-comptage)', () => {
const dom = setup();
const doc = dom.window.document;
counterOrder.init(doc);
// Cheeseburger (12) porte un bouton Personnaliser : son qty_<id> est ignore par le
// JS pour eviter le double comptage avec la ligne configuree.
doc.getElementById('qty_12').value = '3';
fireSubmit(dom);
assert.deepEqual(itemsJson(dom), []);
});
test('personnalisation produit (retrait + ajout) -> items_json porte modifiers:[remove, add]', () => {
const dom = setup();
const doc = dom.window.document;
counterOrder.init(doc);
// Ouvre la modale du produit 12 (Cheeseburger).
doc.querySelector('.product-configure[data-product-id="12"]').dispatchEvent(new dom.window.Event('click', { bubbles: true }));
const modal = doc.getElementById('menu-composer-modal');
assert.equal(modal.hasAttribute('hidden'), false);
// Coche "sans Oignon" (retrait, ingredient 3) et "extra Bacon" (ajout, ingredient 8).
const removeBox = modal.querySelector('.menu-composer__modifier-remove[data-ingredient-id="3"]');
removeBox.checked = true;
removeBox.dispatchEvent(new dom.window.Event('change', { bubbles: true }));
const addBox = modal.querySelector('.menu-composer__modifier-add[data-ingredient-id="8"]');
addBox.checked = true;
addBox.dispatchEvent(new dom.window.Event('change', { bubbles: true }));
modal.querySelector('.menu-composer__add').dispatchEvent(new dom.window.Event('click', { bubbles: true }));
assert.equal(modal.hasAttribute('hidden'), true);
assert.ok(doc.querySelector('.order-cart__line'));
fireSubmit(dom);
const items = itemsJson(dom);
assert.equal(items.length, 1);
assert.equal(items[0].type, 'product');
assert.equal(items[0].product_id, 12);
assert.equal(items[0].quantity, 1);
assert.deepEqual(items[0].modifiers, [
{ ingredient_id: 3, action: 'remove' },
{ ingredient_id: 8, action: 'add' },
]);
});
test('quantite 0 ignoree -> panier vide serialise []', () => {
const dom = setup();
counterOrder.init(dom.window.document);
fireSubmit(dom);
assert.deepEqual(itemsJson(dom), []);
});
test('configuration menu (format Maxi + slots) -> items_json contient {type:menu, format:maxi, selections}', () => {
const dom = setup();
const doc = dom.window.document;
counterOrder.init(doc);
// Ouvre la modale du menu 5.
doc.querySelector('.menu-configure[data-menu-id="5"]').dispatchEvent(new dom.window.Event('click', { bubbles: true }));
const modal = doc.getElementById('menu-composer-modal');
assert.equal(modal.hasAttribute('hidden'), false);
// Passe en Maxi.
const maxiRadio = Array.prototype.find.call(
modal.querySelectorAll('.menu-composer__format-input'),
r => r.value === 'maxi',
);
maxiRadio.checked = true;
maxiRadio.dispatchEvent(new dom.window.Event('change', { bubbles: true }));
// Slots requis (side/drink) sont pre-selectionnes (1er choix) ; on ajoute la sauce.
const sauceSelect = Array.prototype.find.call(
modal.querySelectorAll('.menu-composer__slot-select'),
s => s.dataset.slotId === '31',
);
sauceSelect.value = '47';
sauceSelect.dispatchEvent(new dom.window.Event('change', { bubbles: true }));
modal.querySelector('.menu-composer__add').dispatchEvent(new dom.window.Event('click', { bubbles: true }));
// Modale fermee, panier recap mis a jour.
assert.equal(modal.hasAttribute('hidden'), true);
assert.ok(doc.querySelector('.order-cart__line'));
fireSubmit(dom);
const items = itemsJson(dom);
assert.equal(items.length, 1);
assert.equal(items[0].type, 'menu');
assert.equal(items[0].menu_id, 5);
assert.equal(items[0].format, 'maxi');
assert.equal(items[0].quantity, 1);
// Selections : slots tries par display_order (drink=1, side=2, sauce=3).
assert.deepEqual(items[0].selections, [
{ menu_slot_id: 1, product_id: 14 },
{ menu_slot_id: 16, product_id: 22 },
{ menu_slot_id: 31, product_id: 47 },
]);
});
test('menu Normal sans la sauce optionnelle -> selections ne contient que les requis', () => {
const dom = setup();
const doc = dom.window.document;
counterOrder.init(doc);
doc.querySelector('.menu-configure[data-menu-id="5"]').dispatchEvent(new dom.window.Event('click', { bubbles: true }));
const modal = doc.getElementById('menu-composer-modal');
// Laisse la sauce a "Sans" (valeur vide) ; ajoute directement.
const sauceSelect = Array.prototype.find.call(
modal.querySelectorAll('.menu-composer__slot-select'),
s => s.dataset.slotId === '31',
);
sauceSelect.value = '';
sauceSelect.dispatchEvent(new dom.window.Event('change', { bubbles: true }));
modal.querySelector('.menu-composer__add').dispatchEvent(new dom.window.Event('click', { bubbles: true }));
fireSubmit(dom);
const items = itemsJson(dom);
assert.equal(items[0].format, 'normal');
assert.deepEqual(items[0].selections, [
{ menu_slot_id: 1, product_id: 14 },
{ menu_slot_id: 16, product_id: 22 },
]);
});
test('produit + menu combines -> items_json contient les deux lignes', () => {
const dom = setup();
const doc = dom.window.document;
counterOrder.init(doc);
// Frites (22) sans modificateur -> chemin qty_<id>.
doc.getElementById('qty_22').value = '1';
doc.querySelector('.menu-configure[data-menu-id="5"]').dispatchEvent(new dom.window.Event('click', { bubbles: true }));
const modal = doc.getElementById('menu-composer-modal');
modal.querySelector('.menu-composer__add').dispatchEvent(new dom.window.Event('click', { bubbles: true }));
fireSubmit(dom);
const items = itemsJson(dom);
assert.equal(items.length, 2);
assert.equal(items.filter(i => i.type === 'product').length, 1);
assert.equal(items.filter(i => i.type === 'menu').length, 1);
});
test('configuration menu avec modificateur burger -> item menu porte modifiers:[remove]', () => {
const dom = setup();
const doc = dom.window.document;
counterOrder.init(doc);
doc.querySelector('.menu-configure[data-menu-id="5"]').dispatchEvent(new dom.window.Event('click', { bubbles: true }));
const modal = doc.getElementById('menu-composer-modal');
// Retire l'oignon du burger (ingredient 3, is_removable).
const removeBox = modal.querySelector('.menu-composer__modifier-remove[data-ingredient-id="3"]');
removeBox.checked = true;
removeBox.dispatchEvent(new dom.window.Event('change', { bubbles: true }));
modal.querySelector('.menu-composer__add').dispatchEvent(new dom.window.Event('click', { bubbles: true }));
fireSubmit(dom);
const items = itemsJson(dom);
assert.equal(items[0].type, 'menu');
assert.deepEqual(items[0].modifiers, [{ ingredient_id: 3, action: 'remove' }]);
});
test('total + bouton : produit simple (Frites 2,50 x2) -> 5,00 EUR affiche', () => {
const dom = setup();
const doc = dom.window.document;
counterOrder.init(doc);
const qty = doc.getElementById('qty_22'); // Frites, 250c
qty.value = '2';
qty.dispatchEvent(new dom.window.Event('input', { bubbles: true }));
assert.equal(doc.getElementById('order-total-value').textContent, '5,00 EUR');
assert.equal(doc.getElementById('order-submit').textContent, 'Encaisser 5,00 EUR');
});
test('total : produit personnalise avec ajout (Cheeseburger 8,90 + Bacon 0,50) -> 9,40 EUR', () => {
const dom = setup();
const doc = dom.window.document;
counterOrder.init(doc);
doc.querySelector('.product-configure[data-product-id="12"]').dispatchEvent(new dom.window.Event('click', { bubbles: true }));
const modal = doc.getElementById('menu-composer-modal');
const addBox = modal.querySelector('.menu-composer__modifier-add[data-ingredient-id="8"]');
addBox.checked = true;
addBox.dispatchEvent(new dom.window.Event('change', { bubbles: true }));
modal.querySelector('.menu-composer__add').dispatchEvent(new dom.window.Event('click', { bubbles: true }));
// Prix de ligne affiche dans le panier.
assert.equal(doc.querySelector('.order-cart__price').textContent, '9,40 EUR');
assert.equal(doc.getElementById('order-total-value').textContent, '9,40 EUR');
});
test('total : menu Maxi (11,90) inclus dans le total de ligne', () => {
const dom = setup();
const doc = dom.window.document;
counterOrder.init(doc);
doc.querySelector('.menu-configure[data-menu-id="5"]').dispatchEvent(new dom.window.Event('click', { bubbles: true }));
const modal = doc.getElementById('menu-composer-modal');
const maxiRadio = Array.prototype.find.call(
modal.querySelectorAll('.menu-composer__format-input'),
r => r.value === 'maxi',
);
maxiRadio.checked = true;
maxiRadio.dispatchEvent(new dom.window.Event('change', { bubbles: true }));
modal.querySelector('.menu-composer__add').dispatchEvent(new dom.window.Event('click', { bubbles: true }));
assert.equal(doc.querySelector('.order-cart__price').textContent, '11,90 EUR');
assert.equal(doc.getElementById('order-total-value').textContent, '11,90 EUR');
});
test('numero de table : masque hors sur place, visible en sur place (toggle service_mode)', () => {
const dom = setup();
const doc = dom.window.document;
counterOrder.init(doc);
const group = doc.getElementById('service_tag_group');
const select = doc.getElementById('service_mode');
// Init : dine_in pre-selectionne -> visible.
assert.equal(group.hasAttribute('hidden'), false);
select.value = 'takeaway';
select.dispatchEvent(new dom.window.Event('change', { bubbles: true }));
assert.equal(group.hasAttribute('hidden'), true);
select.value = 'dine_in';
select.dispatchEvent(new dom.window.Event('change', { bubbles: true }));
assert.equal(group.hasAttribute('hidden'), false);
});
test('modale menu : slot requis non choisi -> message inline, pas d ajout muet', () => {
const dom = setup();
const doc = dom.window.document;
counterOrder.init(doc);
doc.querySelector('.menu-configure[data-menu-id="5"]').dispatchEvent(new dom.window.Event('click', { bubbles: true }));
const modal = doc.getElementById('menu-composer-modal');
// Vide un slot requis (drink, slot 1) en le passant a une valeur absente : on force
// l'etat en deselectionnant via un select dont l'option vide n'existe pas. On
// simule en retirant la selection requise par un slot side (16) mis a vide n'est pas
// possible (requis) ; on retire plutot la pre-selection en posant une valeur hors options.
// Plus simple : on supprime l'option pre-cochee du slot requis 'drink' (1) en le
// forcant a une chaine vide via le select (un slot requis n'a pas d'option Sans, mais
// jsdom autorise l'affectation d'une value vide -> change supprime la selection).
const drinkSelect = Array.prototype.find.call(
modal.querySelectorAll('.menu-composer__slot-select'),
s => s.dataset.slotId === '1',
);
drinkSelect.value = '';
drinkSelect.dispatchEvent(new dom.window.Event('change', { bubbles: true }));
// Le <p role=alert> est present des l'ouverture (vide), avant toute erreur.
const errAtOpen = modal.querySelector('.menu-composer__error');
assert.ok(errAtOpen);
assert.equal(errAtOpen.getAttribute('role'), 'alert');
assert.equal(errAtOpen.textContent, '');
assert.equal(errAtOpen.hasAttribute('hidden'), false); // present en permanence (a11y)
modal.querySelector('.menu-composer__add').dispatchEvent(new dom.window.Event('click', { bubbles: true }));
// Modale encore ouverte, message inline renseigne (textContent), aucune ligne.
assert.equal(modal.hasAttribute('hidden'), false);
assert.notEqual(errAtOpen.textContent, '');
assert.equal(doc.querySelector('.order-cart__line'), null);
});
test('produit personnalisable : champ qty editable en HTML, desactive par JS + indice revele', () => {
const dom = setup();
const doc = dom.window.document;
// Avant init : le champ qty d'un produit a modificateurs est editable (repli sans JS)
// et l'indice "via Personnaliser" est cache.
const qty = doc.getElementById('qty_12');
const hint = doc.querySelector('[data-qty-hint="12"]');
assert.equal(qty.disabled, false);
assert.equal(hint.hidden, true);
counterOrder.init(doc);
// Apres init (JS present) : champ neutralise et indice revele.
assert.equal(qty.disabled, true);
assert.equal(hint.hidden, false);
// Un produit SANS modificateur reste editable (pas d'indice).
assert.equal(doc.getElementById('qty_22').disabled, false);
assert.equal(doc.querySelector('[data-qty-hint="22"]'), null);
});
test('modale : focus restaure sur le bouton declencheur a la fermeture', () => {
const dom = setup();
const doc = dom.window.document;
counterOrder.init(doc);
const trigger = doc.querySelector('.menu-configure[data-menu-id="5"]');
trigger.focus();
assert.equal(doc.activeElement, trigger);
trigger.dispatchEvent(new dom.window.Event('click', { bubbles: true }));
const modal = doc.getElementById('menu-composer-modal');
// Le focus est entre dans la modale (plus sur le bouton declencheur).
assert.notEqual(doc.activeElement, trigger);
doc.dispatchEvent(new dom.window.KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
// Ferme -> focus restaure sur le declencheur.
assert.equal(doc.activeElement, trigger);
});
test('modale : panel porte role=dialog, aria-modal et aria-labelledby (titre)', () => {
const dom = setup();
const doc = dom.window.document;
counterOrder.init(doc);
doc.querySelector('.menu-configure[data-menu-id="5"]').dispatchEvent(new dom.window.Event('click', { bubbles: true }));
const panel = doc.querySelector('.menu-composer');
assert.equal(panel.getAttribute('role'), 'dialog');
assert.equal(panel.getAttribute('aria-modal'), 'true');
const labelledby = panel.getAttribute('aria-labelledby');
assert.ok(labelledby);
const title = doc.getElementById(labelledby);
assert.ok(title);
assert.equal(title.classList.contains('menu-composer__title'), true);
});
test('total : separateur de milliers aligne sur PHP (1 234,50 EUR)', () => {
// Produit a 617,25 EUR (61725c) x2 = 1 234,50 EUR -> espace separateur de milliers.
const PRICEY = [{ id: 99, name: 'Plateau', price: 61725, modifiers: [] }];
const dom = new JSDOM(
'<!DOCTYPE html><html><body>' +
'<form id="counter-order-form" method="post" action="/counter/orders" ' +
` data-products='${JSON.stringify(PRICEY)}' data-menus='[]'>` +
' <input type="hidden" name="items_json" id="items_json" value="">' +
' <input class="order-qty" type="number" id="qty_99" name="qty_99" data-product-id="99" value="0">' +
' <ul id="menu-list"></ul>' +
' <ul id="order-cart"><li id="order-cart-empty">Panier vide.</li></ul>' +
' <p><span id="order-total-value">0,00 EUR</span></p>' +
' <button type="submit" id="order-submit">Encaisser 0,00 EUR</button>' +
'</form>' +
'<div id="menu-composer-modal" hidden></div>' +
'</body></html>',
);
const doc = dom.window.document;
counterOrder.init(doc);
const qty = doc.getElementById('qty_99');
qty.value = '2';
qty.dispatchEvent(new dom.window.Event('input', { bubbles: true }));
assert.equal(doc.getElementById('order-total-value').textContent, '1 234,50 EUR');
assert.equal(doc.getElementById('order-submit').textContent, 'Encaisser 1 234,50 EUR');
});
test('modale : touche Echap ferme la modale', () => {
const dom = setup();
const doc = dom.window.document;
counterOrder.init(doc);
doc.querySelector('.menu-configure[data-menu-id="5"]').dispatchEvent(new dom.window.Event('click', { bubbles: true }));
const modal = doc.getElementById('menu-composer-modal');
assert.equal(modal.hasAttribute('hidden'), false);
doc.dispatchEvent(new dom.window.KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
assert.equal(modal.hasAttribute('hidden'), true);
});
test('composerSteps: slot_type non gere (dessert) ignore, slots tries par display_order', () => {
const productById = {};
PRODUCTS.forEach(p => { productById[p.id] = p; });
const menu = {
id: 9,
slots: [
{ id: 99, name: 'Dessert', slot_type: 'dessert', is_required: 1, display_order: 4, option_product_ids: [22] },
...MENUS[0].slots,
],
};
const steps = counterOrder.composerSteps(menu, productById);
assert.deepEqual(steps.map(s => s.slotType), ['drink', 'side', 'sauce']); // dessert exclu, tri display_order
});