/* * 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 => `
  • `) .join(''); // qty_ 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 ? `` : ''; const hint = hasMods ? `` : ''; return `${hint}${configure}`; }) .join(''); const dom = new JSDOM( '' + '
    ` + ' ' + ' ' + '
    ' + productRows + ' ' + ' ' + '

    Total : 0,00 EUR

    ' + ' ' + '
    ' + '' + '', ); 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_. 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_ 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_. 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

    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( '' + '

    ` + ' ' + ' ' + ' ' + '
    • Panier vide.
    ' + '

    0,00 EUR

    ' + ' ' + '
    ' + '' + '', ); 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 });