feat(catalogue): options de slot de menu filtrees par type (slot_type vers categorie) + garde serveur
All checks were successful
CI / secret-scan (push) Successful in 22s
CI / php-lint (push) Successful in 33s
CI / static-tests (push) Successful in 1m19s
CI / js-tests (push) Successful in 49s
CI / secret-scan (pull_request) Successful in 29s
CI / php-lint (pull_request) Successful in 40s
CI / static-tests (pull_request) Successful in 1m31s
CI / js-tests (pull_request) Successful in 55s

This commit is contained in:
Imugiii 2026-06-25 12:13:14 +00:00
parent be4585aeb2
commit 774f88a06f
8 changed files with 534 additions and 133 deletions

View file

@ -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.

View file

@ -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<int, array<string, mixed>>
*/
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<string, mixed>|null
*/

View file

@ -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<string, list<string>>
*/
private const SLOT_CATEGORIES = [
'drink' => ['boissons'],
'sauce' => ['sauces'],
'dessert' => ['desserts'],
'side' => ['frites', 'encas', 'salades'],
'extra' => ['boissons', 'frites', 'encas', 'wraps', 'salades', 'desserts', 'sauces'],
];
/**
* @param array<string, string> $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'] ?? ''),

View file

@ -10,8 +10,10 @@ declare(strict_types=1);
*
* @var int $menuId
* @var array<int, array<string, mixed>> $categories
* @var array<int, array<string, mixed>> $products
* @var array<int, array<string, mixed>> $products burgers de base (select ancre)
* @var array<int, array<string, mixed>> $slotProducts options de slot {id, name, category_slug} (F12)
* @var list<string> $slotTypes
* @var array<string, list<string>> $slotCategories slot_type -> categories autorisees (F12)
* @var array<string, mixed> $values
* @var string $slotsJson
* @var array<string, string> $errors
@ -30,8 +32,12 @@ $errs = isset($errors) && is_array($errors) ? $errors : [];
$cats = isset($categories) && is_array($categories) ? $categories : [];
/** @var array<int, array<string, mixed>> $prods */
$prods = isset($products) && is_array($products) ? $products : [];
/** @var array<int, array<string, mixed>> $slotProds */
$slotProds = isset($slotProducts) && is_array($slotProducts) ? $slotProducts : [];
/** @var list<string> $types */
$types = isset($slotTypes) && is_array($slotTypes) ? $slotTypes : [];
/** @var array<string, list<string>> $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 !== '' ? $
<div id="slot-builder"
data-products="<?= $attr($slimProducts) ?>"
data-slot-types="<?= $attr($types) ?>"
data-slot-categories="<?= $attr($slotCats) ?>"
data-slots="<?= htmlspecialchars($slotsData, ENT_QUOTES, 'UTF-8') ?>"></div>
<button class="btn btn-secondary" type="button" id="add-slot">Ajouter un slot</button>
</fieldset>

View file

@ -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 <select> modifiable, la liste d'options est RE-RENDUE a chaque changement
* de type. Le mapping vient de MenuController::SLOT_CATEGORIES (source unique, aussi
* appliquee par la garde serveur parseSlots).
*
* Module CommonJS (admin = racine CommonJS, comme pin-modal.js / counter-order.js) :
* init(doc) est exporte pour les tests et auto-appele au DOMContentLoaded en prod.
*/
(function () {
'use strict';
var builder = document.getElementById('slot-builder');
var form = document.getElementById('menu-form');
var hidden = document.getElementById('slots_json');
var addBtn = document.getElementById('add-slot');
if (!builder || !form || !hidden || !addBtn) {
return;
function el(doc, tag, className) {
var e = doc.createElement(tag);
if (className) {
e.className = className;
}
return e;
}
function parseData(key, fallback) {
// Lit un attribut data-* JSON et retourne un Array, sinon le fallback (forme tableau).
function parseArray(builder, key, fallback) {
try {
var v = JSON.parse(builder.dataset[key] || fallback);
return Array.isArray(v) ? v : JSON.parse(fallback);
@ -27,34 +38,99 @@
}
}
var products = parseData('products', '[]'); // [{id, name}]
var slotTypes = parseData('slotTypes', '[]'); // ['drink', 'side', ...]
var initialSlots = parseData('slots', '[]'); // [{name, slot_type, is_required, options:[id]}]
function el(tag, className) {
var e = document.createElement(tag);
if (className) {
e.className = className;
// Lit un attribut data-* JSON et retourne un objet simple (le mapping slot_type ->
// categories), sinon {} ; tolerant aux entrees non-objet / mal formees.
function parseObject(builder, key) {
try {
var v = JSON.parse(builder.dataset[key] || '{}');
return (v && typeof v === 'object' && !Array.isArray(v)) ? v : {};
} catch (e) {
return {};
}
}
// Categories eligibles pour un slot_type donne (liste, vide si type inconnu).
function allowedCategories(slotCategories, slotType) {
var list = slotCategories[slotType];
return Array.isArray(list) ? list : [];
}
// Un produit est-il proposable dans un slot de ce type ? (sa categorie figure dans
// la liste autorisee). Source unique de la decision UI, miroir de la garde serveur.
function productAllowed(product, slotCategories, slotType) {
return allowedCategories(slotCategories, slotType).indexOf(String(product.category)) !== -1;
}
function init(doc) {
var builder = doc.getElementById('slot-builder');
var form = doc.getElementById('menu-form');
var hidden = doc.getElementById('slots_json');
var addBtn = doc.getElementById('add-slot');
if (!builder || !form || !hidden || !addBtn) {
return;
}
var products = parseArray(builder, 'products', '[]'); // [{id, name, category}]
var slotTypes = parseArray(builder, 'slotTypes', '[]'); // ['drink', 'side', ...]
var slotCategories = parseObject(builder, 'slotCategories'); // {drink:['boissons'], ...}
var initialSlots = parseArray(builder, 'slots', '[]'); // [{name, slot_type, is_required, options:[id]}]
// (Re)construit la liste des cases a cocher d'options pour un type de slot donne.
// N'affiche QUE les produits dont la categorie est eligible (F12). Les ids deja
// coches qui restent eligibles sont conserves coches ; un id devenu non eligible
// (changement de type) DISPARAIT simplement de la liste : c'est le comportement
// le plus previsible pour un equipier non technicien (une option absente ne peut
// pas etre soumise par megarde), et la garde serveur rejetterait de toute facon
// une option hors categorie. selectedSet : set d'ids coches a preserver.
function renderOptions(optWrap, slotType, selectedSet) {
optWrap.textContent = '';
var shown = 0;
products.forEach(function (p) {
if (!productAllowed(p, slotCategories, slotType)) {
return;
}
shown += 1;
var lab = el(doc, 'label');
lab.style.display = 'block';
var cb = el(doc, 'input', 'slot-option');
cb.type = 'checkbox';
cb.value = String(p.id);
if (selectedSet[String(p.id)]) {
cb.checked = true;
}
lab.appendChild(cb);
lab.appendChild(doc.createTextNode(' ' + String(p.name)));
optWrap.appendChild(lab);
});
// Repere visible quand aucun produit n'est eligible (ex. type sans catalogue) :
// evite une zone vide muette pour l'equipier.
if (shown === 0) {
var empty = el(doc, 'p');
empty.className = 'slot-options-empty';
empty.textContent = 'Aucun produit disponible pour ce type de slot.';
optWrap.appendChild(empty);
}
return e;
}
// Construit le bloc DOM d'un slot. `slot` peut etre vide (creation).
function renderSlot(slot) {
slot = slot || {};
var selectedOptions = Array.isArray(slot.options) ? slot.options.map(Number) : [];
var selectedSet = {};
(Array.isArray(slot.options) ? slot.options : []).forEach(function (id) {
selectedSet[String(Number(id))] = true;
});
var block = el('fieldset', 'slot-block form-group');
var block = el(doc, 'fieldset', 'slot-block form-group');
block.style.border = '1px solid #ddd';
block.style.padding = '0.75rem';
block.style.marginBottom = '0.75rem';
var head = el('div');
var head = el(doc, 'div');
// Nom du slot
var nameLabel = el('label');
nameLabel.appendChild(document.createTextNode('Nom du slot '));
var nameInput = el('input', 'form-input slot-name');
var nameLabel = el(doc, 'label');
nameLabel.appendChild(doc.createTextNode('Nom du slot '));
var nameInput = el(doc, 'input', 'form-input slot-name');
nameInput.type = 'text';
nameInput.maxLength = 80;
nameInput.value = slot.name ? String(slot.name) : '';
@ -62,11 +138,11 @@
head.appendChild(nameLabel);
// Type
var typeLabel = el('label');
typeLabel.appendChild(document.createTextNode(' Type '));
var typeSelect = el('select', 'form-input slot-type');
var typeLabel = el(doc, 'label');
typeLabel.appendChild(doc.createTextNode(' Type '));
var typeSelect = el(doc, 'select', 'form-input slot-type');
slotTypes.forEach(function (t) {
var opt = el('option');
var opt = el(doc, 'option');
opt.value = String(t);
opt.textContent = String(t);
if (String(slot.slot_type) === String(t)) {
@ -78,18 +154,18 @@
head.appendChild(typeLabel);
// Requis
var reqLabel = el('label');
var reqInput = el('input', 'slot-required');
var reqLabel = el(doc, 'label');
var reqInput = el(doc, 'input', 'slot-required');
reqInput.type = 'checkbox';
if (Number(slot.is_required) === 1) {
reqInput.checked = true;
}
reqLabel.appendChild(reqInput);
reqLabel.appendChild(document.createTextNode(' Requis'));
reqLabel.appendChild(doc.createTextNode(' Requis'));
head.appendChild(reqLabel);
// Retirer
var removeBtn = el('button', 'btn btn-secondary slot-remove');
var removeBtn = el(doc, 'button', 'btn btn-secondary slot-remove');
removeBtn.type = 'button';
removeBtn.textContent = 'Retirer';
removeBtn.addEventListener('click', function () {
@ -99,26 +175,30 @@
block.appendChild(head);
// Options : cases a cocher des produits eligibles
var optWrap = el('div', 'slot-options');
// Options : cases a cocher des produits eligibles AU TYPE COURANT (F12).
var optWrap = el(doc, 'div', 'slot-options');
optWrap.style.maxHeight = '160px';
optWrap.style.overflowY = 'auto';
optWrap.style.marginTop = '0.5rem';
products.forEach(function (p) {
var lab = el('label');
lab.style.display = 'block';
var cb = el('input', 'slot-option');
cb.type = 'checkbox';
cb.value = String(p.id);
if (selectedOptions.indexOf(Number(p.id)) !== -1) {
cb.checked = true;
}
lab.appendChild(cb);
lab.appendChild(document.createTextNode(' ' + String(p.name)));
optWrap.appendChild(lab);
});
// Type initial : la valeur du slot (edition) ou le 1er type (creation), pour
// matcher l'option selectionnee par defaut dans le <select> 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;
}
@ -157,4 +237,14 @@
} else {
builder.appendChild(renderSlot(null));
}
}
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);
});
}
})();

View file

@ -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')) {

View file

@ -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();

152
tests/js/menu-form.test.js Normal file
View file

@ -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(
'<!DOCTYPE html><html><body>' +
'<form id="menu-form" method="post" action="/admin/menus">' +
' <div id="slot-builder"' +
' data-products=\'' + JSON.stringify(PRODUCTS) + '\'' +
' data-slot-types=\'' + JSON.stringify(SLOT_TYPES) + '\'' +
' data-slot-categories=\'' + JSON.stringify(SLOT_CATEGORIES) + '\'' +
' data-slots=\'' + JSON.stringify(slots || []) + '\'></div>' +
' <button type="button" id="add-slot">Ajouter un slot</button>' +
' <input type="hidden" name="slots_json" id="slots_json" value="">' +
' <button type="submit">Enregistrer</button>' +
'</form></body></html>',
);
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]);
});