diff --git a/src/app/Catalogue/MenuRepository.php b/src/app/Catalogue/MenuRepository.php index 16754c6..e19e062 100644 --- a/src/app/Catalogue/MenuRepository.php +++ b/src/app/Catalogue/MenuRepository.php @@ -164,6 +164,27 @@ final class MenuRepository ) !== null; } + /** + * Slug de categorie d'un produit, ou null si l'id est inconnu. Garde serveur F12 : + * une option de slot doit appartenir a une categorie autorisee pour le slot_type + * du slot (mapping unique cote MenuController). Le controleur croise ce slug avec + * la liste autorisee et rejette (422) une option hors categorie meme si l'UI de + * filtrage est contournee -- defense en profondeur (RG-T18), par-dessus la garde + * base-only existante (productIsBase, F9). + */ + public function productCategorySlug(int $id): ?string + { + $row = $this->db->fetch( + 'SELECT c.slug AS category_slug FROM product p ' + . 'JOIN category c ON c.id = p.category_id WHERE p.id = :id', + ['id' => $id], + ); + + $slug = $row['category_slug'] ?? null; + + return is_string($slug) ? $slug : null; + } + /** * Pre-verification FK-safe (mlt 8.6 RG-1) : le menu est-il reference par une * ligne de commande historique ? La FK order_item.menu_id est RESTRICT. diff --git a/src/app/Catalogue/ProductRepository.php b/src/app/Catalogue/ProductRepository.php index 570b855..cb3c7dc 100644 --- a/src/app/Catalogue/ProductRepository.php +++ b/src/app/Catalogue/ProductRepository.php @@ -73,6 +73,29 @@ final class ProductRepository ); } + /** + * Produits de BASE (base_product_id IS NULL, R4) avec le slug de leur CATEGORIE, + * pour alimenter les OPTIONS de slot du formulaire menu (F12). Le formulaire doit + * filtrer les options proposees selon le type de slot (drink -> boissons, etc.) ; + * il lui faut donc la categorie de chaque produit, que basesOnly() (projection + * stricte {id, name}) ne porte pas. Methode dediee plutot qu'extension de + * basesOnly() : ce dernier alimente aussi le select base_product_id du formulaire + * produit (ProductController), qui n'a pas besoin de la categorie -- garder son + * contrat minimal evite un couplage inutile. Meme predicat anti-variante que + * basesOnly(), miroir de la garde serveur MenuRepository::productIsBase(). + * + * @return array> + */ + public function baseOptionsWithCategory(): array + { + return $this->db->fetchAll( + 'SELECT p.id, p.name, c.slug AS category_slug ' + . 'FROM product p JOIN category c ON c.id = p.category_id ' + . 'WHERE p.base_product_id IS NULL ' + . 'ORDER BY p.display_order, p.name', + ); + } + /** * @return array|null */ diff --git a/src/app/Controllers/MenuController.php b/src/app/Controllers/MenuController.php index 90d72ac..96cdf95 100644 --- a/src/app/Controllers/MenuController.php +++ b/src/app/Controllers/MenuController.php @@ -36,6 +36,32 @@ class MenuController extends AdminController { private const SLOT_TYPES = ['drink', 'side', 'sauce', 'dessert', 'extra']; + /** + * F12 : SOURCE UNIQUE du mapping slot_type -> categorie(s) eligibles (slugs FR du + * seed 0002). Une option de slot ne peut etre qu'un produit dont la categorie est + * dans la liste de son slot_type. Ce meme tableau est (a) passe a la vue puis au + * builder JS (filtrage UI dynamique), et (b) reutilise par la garde serveur + * parseSlots() (rejet 422 d'une option hors categorie) -- pas de double definition + * divergente. + * + * Regle metier (decidee avec l'utilisateur) : + * - drink/sauce/dessert : leur categorie homonyme ; + * - side : les accompagnements (frites + encas + salades) ; + * - extra : slot LIBRE = toute categorie SAUF 'menus' (pas de menu dans un menu) + * et 'burgers' (le burger est l'ancre du menu, champ separe, jamais une option). + * 'menus' n'apparait dans aucune liste : un menu ne se compose pas d'un autre menu. + * Le slug 'wraps' n'est eligible que via 'extra' (slot libre). + * + * @var array> + */ + private const SLOT_CATEGORIES = [ + 'drink' => ['boissons'], + 'sauce' => ['sauces'], + 'dessert' => ['desserts'], + 'side' => ['frites', 'encas', 'salades'], + 'extra' => ['boissons', 'frites', 'encas', 'wraps', 'salades', 'desserts', 'sauces'], + ]; + /** * @param array $params */ @@ -388,13 +414,22 @@ class MenuController extends AdminController $slotType = is_string($raw['slot_type'] ?? null) ? $raw['slot_type'] : ''; $required = !empty($raw['is_required']) ? 1 : 0; + // Categories autorisees pour ce slot_type (F12, mapping unique). Tableau + // vide pour un slot_type inconnu : aucune option n'y est alors eligible, + // mais le type invalide est rejete plus bas avant d'utiliser ce resultat. + $allowedCategories = self::SLOT_CATEGORIES[$slotType] ?? []; + // F9-2 : une option de slot doit etre un produit de BASE (R4). Un id de // variante de taille (base_product_id non nul) est REJETE explicitement // (422) plutot que filtre en silence : choisir une variante comme option // serait un contournement de l'UI base-only, et un drop muet ferait perdre // un choix sans message clair. Un id inconnu reste filtre (allowlist). + // F12 : par-dessus, l'option doit appartenir a une categorie autorisee pour + // le slot_type ; une option hors categorie est REJETEE (422), pas filtree, + // pour la meme raison (contournement de l'UI de filtrage = erreur visible). $optionIds = []; $hasVariantOption = false; + $hasWrongCategoryOption = false; foreach (is_array($raw['options'] ?? null) ? $raw['options'] : [] as $opt) { $pid = is_numeric($opt) ? (int) $opt : 0; if ($pid <= 0 || !$this->menuRepository()->productExists($pid)) { @@ -404,6 +439,11 @@ class MenuController extends AdminController $hasVariantOption = true; continue; // variante de taille : non eligible comme option de menu } + $categorySlug = $this->menuRepository()->productCategorySlug($pid); + if ($categorySlug === null || !in_array($categorySlug, $allowedCategories, true)) { + $hasWrongCategoryOption = true; + continue; // hors categorie pour ce slot_type : non eligible + } $optionIds[] = $pid; } $optionIds = array_values(array_unique($optionIds)); @@ -420,6 +460,10 @@ class MenuController extends AdminController $errors['slots'] = 'Une variante de taille ne peut pas etre proposee comme option de menu (choisissez le produit de base).'; continue; } + if ($hasWrongCategoryOption) { + $errors['slots'] = 'Une option proposee n\'appartient pas a une categorie compatible avec le type de ce slot.'; + continue; + } if ($optionIds === []) { $errors['slots'] = 'Chaque slot doit proposer au moins une option valide.'; continue; @@ -479,9 +523,16 @@ class MenuController extends AdminController 'categories' => $this->categoryRepository()->all(), // F9-1 : listes deroulantes base-only (burger principal + options de // slot). basesOnly() exclut les variantes de taille (R4) ; all() les - // inclut (liste admin), il ne doit donc pas alimenter ces selects. + // inclut (liste admin), il ne doit donc pas alimenter ces selects. Le + // burger principal (select dedie) consomme cette liste {id, name}. 'products' => $this->productRepository()->basesOnly(), + // F12 : options de slot, base-only ENRICHIES du slug de categorie, pour + // que le builder JS filtre les choix proposes selon le type de slot. + 'slotProducts' => $this->productRepository()->baseOptionsWithCategory(), 'slotTypes' => self::SLOT_TYPES, + // F12 : mapping slot_type -> categories, source unique partagee avec la + // garde serveur (self::SLOT_CATEGORIES) et exposee au builder JS. + 'slotCategories' => self::SLOT_CATEGORIES, 'values' => [ 'category_id' => (string) ($values['category_id'] ?? ''), 'burger_product_id' => (string) ($values['burger_product_id'] ?? ''), diff --git a/src/app/Views/admin/menus/form.php b/src/app/Views/admin/menus/form.php index de4ba78..27d3fc6 100644 --- a/src/app/Views/admin/menus/form.php +++ b/src/app/Views/admin/menus/form.php @@ -10,8 +10,10 @@ declare(strict_types=1); * * @var int $menuId * @var array> $categories - * @var array> $products + * @var array> $products burgers de base (select ancre) + * @var array> $slotProducts options de slot {id, name, category_slug} (F12) * @var list $slotTypes + * @var array> $slotCategories slot_type -> categories autorisees (F12) * @var array $values * @var string $slotsJson * @var array $errors @@ -30,8 +32,12 @@ $errs = isset($errors) && is_array($errors) ? $errors : []; $cats = isset($categories) && is_array($categories) ? $categories : []; /** @var array> $prods */ $prods = isset($products) && is_array($products) ? $products : []; +/** @var array> $slotProds */ +$slotProds = isset($slotProducts) && is_array($slotProducts) ? $slotProducts : []; /** @var list $types */ $types = isset($slotTypes) && is_array($slotTypes) ? $slotTypes : []; +/** @var array> $slotCats */ +$slotCats = isset($slotCategories) && is_array($slotCategories) ? $slotCategories : []; $val = static fn (string $k): string => htmlspecialchars((string) ($vals[$k] ?? ''), ENT_QUOTES, 'UTF-8'); $err = static fn (string $k): string => isset($errs[$k]) && is_string($errs[$k]) ? $errs[$k] : ''; @@ -41,9 +47,16 @@ $available = (bool) ($vals['is_available'] ?? true); // Donnees pour le builder JS, passees en attributs data-* (CSP 'self' : pas de // script inline). htmlspecialchars rend le JSON sur-able comme valeur d'attribut. +// F12 : chaque option de slot porte sa categorie (category) pour que le builder +// filtre les choix proposes selon le type de slot ; le mapping slot_type -> +// categories (slotCategories) provient de la meme source que la garde serveur. $slimProducts = array_map( - static fn (array $p): array => ['id' => (int) ($p['id'] ?? 0), 'name' => (string) ($p['name'] ?? '')], - $prods, + static fn (array $p): array => [ + 'id' => (int) ($p['id'] ?? 0), + 'name' => (string) ($p['name'] ?? ''), + 'category' => (string) ($p['category_slug'] ?? ''), + ], + $slotProds, ); $attr = static fn (mixed $data): string => htmlspecialchars( (string) json_encode($data, JSON_UNESCAPED_UNICODE), @@ -124,6 +137,7 @@ $slotsData = isset($slotsJson) && is_string($slotsJson) && $slotsJson !== '' ? $
diff --git a/src/public/admin/assets/js/menu-form.js b/src/public/admin/assets/js/menu-form.js index a45f909..4a827bc 100644 --- a/src/public/admin/assets/js/menu-form.js +++ b/src/public/admin/assets/js/menu-form.js @@ -2,23 +2,34 @@ * menu-form.js — Builder de slots du formulaire menu (back-office). * * CSP 'self' : script externe (pas d'inline). Les donnees (produits, types, - * slots initiaux) sont lues depuis les attributs data-* de #slot-builder. - * A la soumission, l'etat des slots est serialise en JSON dans le champ cache - * #slots_json (Request::formBody cote serveur ne garde que les scalaires, d'ou - * le passage par une chaine JSON). Le serveur revalide tout (RG-T18). + * mapping slot_type -> categories, slots initiaux) sont lues depuis les attributs + * data-* de #slot-builder. A la soumission, l'etat des slots est serialise en JSON + * dans le champ cache #slots_json (Request::formBody cote serveur ne garde que les + * scalaires, d'ou le passage par une chaine JSON). Le serveur revalide tout (RG-T18). + * + * F12 : les options proposees dans un slot sont FILTREES par le type de slot. Chaque + * produit porte sa categorie (data-products[].category) ; le mapping slot_type -> + * categories (data-slot-categories) decide quelles categories sont eligibles. Le type + * etant un ci-dessus. + var currentType = String(typeSelect.value || (slotTypes.length ? slotTypes[0] : '')); + renderOptions(optWrap, currentType, selectedSet); + block.appendChild(optWrap); + + // Re-filtrage dynamique : changer le type re-rend les options eligibles. On + // repart des cases actuellement cochees (preservees si encore eligibles), pas + // de la selection initiale : l'equipier ne reperd pas un choix encore valide. + typeSelect.addEventListener('change', function () { + var keep = {}; + Array.prototype.forEach.call(optWrap.querySelectorAll('.slot-option'), function (cb) { + if (cb.checked) { + keep[String(cb.value)] = true; + } + }); + renderOptions(optWrap, String(typeSelect.value), keep); + }); + + return block; + } + + // Lit l'etat des blocs et le serialise dans #slots_json. + function serialize() { + var slots = []; + var blocks = builder.querySelectorAll('.slot-block'); + Array.prototype.forEach.call(blocks, function (block) { + var name = block.querySelector('.slot-name').value.trim(); + var type = block.querySelector('.slot-type').value; + var required = block.querySelector('.slot-required').checked ? 1 : 0; + var options = []; + Array.prototype.forEach.call(block.querySelectorAll('.slot-option'), function (cb) { + if (cb.checked) { + options.push(Number(cb.value)); + } + }); + slots.push({ name: name, slot_type: type, is_required: required, options: options }); + }); + hidden.value = JSON.stringify(slots); + } + + addBtn.addEventListener('click', function () { + builder.appendChild(renderSlot(null)); }); - hidden.value = JSON.stringify(slots); + + form.addEventListener('submit', function () { + serialize(); + }); + + // Rendu initial : slots existants (edition) ou un slot vide (creation). + if (initialSlots.length) { + initialSlots.forEach(function (s) { + builder.appendChild(renderSlot(s)); + }); + } else { + builder.appendChild(renderSlot(null)); + } } - addBtn.addEventListener('click', function () { - builder.appendChild(renderSlot(null)); - }); - - form.addEventListener('submit', function () { - serialize(); - }); - - // Rendu initial : slots existants (edition) ou un slot vide (creation). - if (initialSlots.length) { - initialSlots.forEach(function (s) { - builder.appendChild(renderSlot(s)); + if (typeof module !== 'undefined' && module.exports) { + module.exports = { init: init, productAllowed: productAllowed, allowedCategories: allowedCategories }; + } + if (typeof document !== 'undefined' && document.addEventListener) { + document.addEventListener('DOMContentLoaded', function () { + init(document); }); - } else { - builder.appendChild(renderSlot(null)); } })(); diff --git a/tests/Support/FakeDatabase.php b/tests/Support/FakeDatabase.php index 95d52d0..1f05bde 100644 --- a/tests/Support/FakeDatabase.php +++ b/tests/Support/FakeDatabase.php @@ -158,6 +158,16 @@ final class FakeDatabase implements DatabaseInterface */ public bool $productIsBase = true; + /** + * Slug de categorie renvoye par MenuRepository::productCategorySlug() (garde F12) ; + * null => productCategorySlug() retourne null (id inconnu / produit sans categorie), + * ce qui fait rejeter l'option par le controleur. Defaut 'boissons' : aligne sur le + * slot 'drink' du formulaire valide de reference (validForm), donc une option passe + * la garde de categorie par defaut. Un test le change pour simuler une option hors + * categorie (ex. 'burgers' dans un slot 'drink'). + */ + public ?string $productCategorySlug = 'boissons'; + /** * Ligne renvoyee par MenuRepository::find() ; null = introuvable. * @@ -463,6 +473,14 @@ final class FakeDatabase implements DatabaseInterface return $this->actingUserRow; } + // F12 : slug de categorie d'un produit (productCategorySlug), garde de categorie + // d'option de slot. Distinguee des routes 'FROM product WHERE id' par le JOIN + // category + la projection 'category_slug' ; null => option rejetee (hors + // categorie / id inconnu). Doit passer AVANT les routes produit generiques. + if (str_contains($sql, 'c.slug AS category_slug FROM product p JOIN category c')) { + return $this->productCategorySlug !== null ? ['category_slug' => $this->productCategorySlug] : null; + } + // R4/F9-2 : predicat base-only (productIsBase). Doit passer AVANT la route // generique 'FROM product WHERE id = :id' (productRow) qu'elle matche aussi. if (str_contains($sql, 'FROM product WHERE id = :id') && str_contains($sql, 'base_product_id IS NULL')) { diff --git a/tests/Unit/Admin/MenuControllerTest.php b/tests/Unit/Admin/MenuControllerTest.php index 835d75f..5866d98 100644 --- a/tests/Unit/Admin/MenuControllerTest.php +++ b/tests/Unit/Admin/MenuControllerTest.php @@ -202,6 +202,38 @@ final class MenuControllerTest extends TestCase self::assertFalse($db->wrote('INSERT INTO menu')); } + public function testStoreRejectsOptionOutOfCategoryForSlotType(): void + { + // F12 : garde serveur de categorie. Un slot 'drink' n'autorise que la categorie + // 'boissons' ; une option en categorie 'burgers' (UI de filtrage contournee) est + // rejetee (422), meme si elle existe et est une base. Defense en profondeur + // par-dessus la garde base-only (RG-T18). + $db = $this->permittedDb(); + $db->productIsBase = true; // l'option est bien une base + $db->productCategorySlug = 'burgers'; // ... mais hors categorie pour un slot drink + + $response = $this->controller($this->post($this->validForm(), '/admin/menus'), $db)->store(); + + self::assertSame(422, $response->status()); + self::assertFalse($db->wrote('INSERT INTO menu')); + self::assertStringContainsString('categorie compatible', $response->body()); + } + + public function testStoreAcceptsOptionInAllowedCategory(): void + { + // F12 : symetrique du rejet. Une option en categorie autorisee pour le slot_type + // (slot 'drink' + categorie 'boissons') passe la garde et le menu est cree. + $db = $this->permittedDb(); + $db->productIsBase = true; + $db->productCategorySlug = 'boissons'; // categorie compatible avec un slot 'drink' + + $response = $this->controller($this->post($this->validForm(), '/admin/menus'), $db)->store(); + + self::assertSame(302, $response->status()); + self::assertTrue($db->wrote('INSERT INTO menu')); + self::assertTrue($db->wrote('INSERT INTO menu_slot_option')); + } + public function testStoreRejectsWithoutSlots(): void { $db = $this->permittedDb(); diff --git a/tests/js/menu-form.test.js b/tests/js/menu-form.test.js new file mode 100644 index 0000000..a691bf7 --- /dev/null +++ b/tests/js/menu-form.test.js @@ -0,0 +1,152 @@ +/* + * Tests du builder de slots du formulaire menu (back-office), node:test + jsdom. + * + * F12 : les options proposees dans un slot sont filtrees par le type de slot via le + * mapping slot_type -> categories. Cible : init(doc) (rendu jsdom des slots + filtrage + * dynamique au changement de type) et le predicat pur productAllowed. + * + * menu-form.js est du CommonJS (admin = racine CommonJS, comme pin-modal.js) : + * import par defaut, init(doc) appele sur un document jsdom prepare. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { JSDOM } from 'jsdom'; + +import menuForm from '../../src/public/admin/assets/js/menu-form.js'; + +// Catalogue minimal (base-only) avec categorie par produit, comme baseOptionsWithCategory. +const PRODUCTS = [ + { id: 14, name: 'Coca Cola', category: 'boissons' }, + { id: 15, name: 'Eau', category: 'boissons' }, + { id: 22, name: 'Moyenne Frite', category: 'frites' }, + { id: 30, name: 'Nuggets x4', category: 'encas' }, + { id: 40, name: 'Cesar Classic', category: 'salades' }, + { id: 47, name: 'Ketchup', category: 'sauces' }, + { id: 50, name: 'Brownie', category: 'desserts' }, + { id: 60, name: 'MC Wrap Chevre', category: 'wraps' }, + { id: 70, name: 'Le 280', category: 'burgers' }, +]; + +// Mapping identique a MenuController::SLOT_CATEGORIES (source unique cote serveur). +const SLOT_CATEGORIES = { + drink: ['boissons'], + sauce: ['sauces'], + dessert: ['desserts'], + side: ['frites', 'encas', 'salades'], + extra: ['boissons', 'frites', 'encas', 'wraps', 'salades', 'desserts', 'sauces'], +}; + +const SLOT_TYPES = ['drink', 'side', 'sauce', 'dessert', 'extra']; + +// Monte un document jsdom porteur du formulaire menu, avec les data-* attendus par +// init(). `slots` pre-remplit le builder (edition) ; vide = un slot vierge (creation). +function setup(slots) { + const dom = new JSDOM( + '' + + '', + ); + return dom.window.document; +} + +// Noms des options affichees dans le 1er bloc slot (ordre du catalogue). +function optionNames(doc) { + const block = doc.querySelector('.slot-block'); + return Array.prototype.map.call(block.querySelectorAll('.slot-option'), (cb) => { + const id = Number(cb.value); + return (PRODUCTS.find((p) => p.id === id) || {}).name; + }); +} + +/* --- productAllowed (pur) ------------------------------------------------- */ + +test('productAllowed: un drink n accepte que les boissons', () => { + assert.equal(menuForm.productAllowed({ category: 'boissons' }, SLOT_CATEGORIES, 'drink'), true); + assert.equal(menuForm.productAllowed({ category: 'sauces' }, SLOT_CATEGORIES, 'drink'), false); +}); + +test('productAllowed: extra accepte tout sauf menus et burgers', () => { + assert.equal(menuForm.productAllowed({ category: 'burgers' }, SLOT_CATEGORIES, 'extra'), false); + assert.equal(menuForm.productAllowed({ category: 'menus' }, SLOT_CATEGORIES, 'extra'), false); + assert.equal(menuForm.productAllowed({ category: 'wraps' }, SLOT_CATEGORIES, 'extra'), true); + assert.equal(menuForm.productAllowed({ category: 'boissons' }, SLOT_CATEGORIES, 'extra'), true); +}); + +/* --- filtrage des options selon le type de slot --------------------------- */ + +test('slot drink (edition) : n affiche que les boissons', () => { + const doc = setup([{ name: 'Boisson', slot_type: 'drink', is_required: 1, options: [14] }]); + menuForm.init(doc); + assert.deepEqual(optionNames(doc), ['Coca Cola', 'Eau']); // pas de frite/sauce/etc. + // L option deja cochee (14) reste cochee. + const checked = doc.querySelector('.slot-option:checked'); + assert.equal(checked.value, '14'); +}); + +test('slot side : affiche frites + encas + salades, pas les boissons ni sauces', () => { + const doc = setup([{ name: 'Accompagnement', slot_type: 'side', is_required: 1, options: [22] }]); + menuForm.init(doc); + assert.deepEqual(optionNames(doc), ['Moyenne Frite', 'Nuggets x4', 'Cesar Classic']); +}); + +test('slot extra : affiche tout sauf burgers (et menus, absent du catalogue de test)', () => { + const doc = setup([{ name: 'Extra', slot_type: 'extra', is_required: 0, options: [] }]); + menuForm.init(doc); + const names = optionNames(doc); + assert.ok(!names.includes('Le 280')); // burger exclu + assert.ok(names.includes('Coca Cola') && names.includes('Ketchup') && names.includes('MC Wrap Chevre')); +}); + +/* --- re-filtrage dynamique au changement de type -------------------------- */ + +test('changer le type d un slot re-filtre les options proposees', () => { + const doc = setup([{ name: 'Boisson', slot_type: 'drink', is_required: 1, options: [14] }]); + menuForm.init(doc); + assert.deepEqual(optionNames(doc), ['Coca Cola', 'Eau']); + + const typeSelect = doc.querySelector('.slot-type'); + typeSelect.value = 'sauce'; + typeSelect.dispatchEvent(new doc.defaultView.Event('change', { bubbles: true })); + + // Apres bascule en 'sauce', seules les sauces restent affichees. + assert.deepEqual(optionNames(doc), ['Ketchup']); + // L ancienne option boisson (14), non eligible en sauce, a disparu de la liste. + assert.equal(doc.querySelector('.slot-option[value="14"]'), null); +}); + +test('changer de type conserve les options cochees encore eligibles', () => { + // drink -> extra : extra inclut boissons, donc les boissons cochees restent cochees. + const doc = setup([{ name: 'Boisson', slot_type: 'drink', is_required: 1, options: [14, 15] }]); + menuForm.init(doc); + + const typeSelect = doc.querySelector('.slot-type'); + typeSelect.value = 'extra'; + typeSelect.dispatchEvent(new doc.defaultView.Event('change', { bubbles: true })); + + const checkedValues = Array.prototype.map.call( + doc.querySelectorAll('.slot-option:checked'), (cb) => cb.value, + ).sort(); + assert.deepEqual(checkedValues, ['14', '15']); // toujours cochees apres bascule +}); + +/* --- serialisation a la soumission ---------------------------------------- */ + +test('soumission : serialise les slots (options cochees) dans #slots_json', () => { + const doc = setup([{ name: 'Boisson', slot_type: 'drink', is_required: 1, options: [14] }]); + menuForm.init(doc); + doc.getElementById('menu-form').dispatchEvent( + new doc.defaultView.Event('submit', { bubbles: true, cancelable: true }), + ); + const payload = JSON.parse(doc.getElementById('slots_json').value); + assert.equal(payload.length, 1); + assert.equal(payload[0].slot_type, 'drink'); + assert.deepEqual(payload[0].options, [14]); +});