diff --git a/publish/build_package.py b/publish/build_package.py index 7ed1ffd0..a0c2e164 100644 --- a/publish/build_package.py +++ b/publish/build_package.py @@ -23,7 +23,7 @@ _BASE_DIR = os.path.dirname(_PUBLISH_DIR) # Sous-ensemble de static/ réellement nécessaire au mode jouable (voir # templates/play.html) — jamais les assets propres à l'éditeur seul # (icônes de l'interface de l'éditeur mises à part, déjà dans icons/). -_STATIC_ITEMS = ["style.css", "csrf_fetch.js", "icons", "branding", "vendor/fonts", "vendor/animate.min.css"] +_STATIC_ITEMS = ["style.css", "csrf_fetch.js", "icons", "branding", "vendor/fonts", "vendor/animate.min.css", "js/play"] _SLUG_PLACEHOLDER = "_SLUG_PLACEHOLDER" diff --git a/static/js/play/__tests__/conditions.test.js b/static/js/play/__tests__/conditions.test.js new file mode 100644 index 00000000..f2d32384 --- /dev/null +++ b/static/js/play/__tests__/conditions.test.js @@ -0,0 +1,119 @@ +// Tests de la logique PURE du moteur (aucun accès DOM) — voir le plan de +// modularisation JS. Lancé via `node --test static/js/play/__tests__/`. +// Ces fonctions lisent des globals `gameData`/`window` (posés par +// play.html en navigateur) — on les simule ici avant chaque test. +const test = require('node:test'); +const assert = require('node:assert/strict'); + +global.window = global.window || {}; +const { + compareValues, + resolveVariablePath, + readFieldValue, + readVariableValue, + evaluateConditionClause, + evaluateConditionNode, +} = require('../conditions.js'); + +function withGameData(data, fn) { + const previous = global.gameData; + global.gameData = data; + try { + fn(); + } finally { + global.gameData = previous; + } +} + +test('compareValues — comparaison numérique', () => { + assert.equal(compareValues('10', 'superieur', '5'), true); + assert.equal(compareValues('3', 'inferieur', '5'), true); + assert.equal(compareValues('5', 'egal', '5'), true); + assert.equal(compareValues('5', 'different', '5'), false); +}); + +test('compareValues — repli sur comparaison de chaînes si non numérique', () => { + assert.equal(compareValues('abc', 'egal', 'abc'), true); + assert.equal(compareValues('abc', 'different', 'xyz'), true); +}); + +test('compareValues — champ booléen, vocabulaire oui/non insensible à la casse', () => { + assert.equal(compareValues(true, 'egal', 'Oui', 'booleen'), true); + assert.equal(compareValues(false, 'egal', 'non', 'booleen'), true); + assert.equal(compareValues(true, 'different', 'non', 'booleen'), true); +}); + +test('resolveVariablePath — chemin vide renvoie la valeur brute', () => { + assert.equal(resolveVariablePath('42', ''), '42'); +}); + +test('resolveVariablePath — chemin .champ / [index] chaînable', () => { + const raw = JSON.stringify({ inventaire: [{ nom: 'clé' }, { nom: 'épée' }] }); + assert.equal(resolveVariablePath(raw, '.inventaire[1].nom'), 'épée'); +}); + +test('resolveVariablePath — JSON invalide ou chemin absent renvoie null', () => { + assert.equal(resolveVariablePath('{pas du json', '.x'), null); + assert.equal(resolveVariablePath('{"a":1}', '.b.c'), null); +}); + +test('readFieldValue — résout CLICKED_ROW_ID (-1) via window.lastClickedRowId', () => { + withGameData({ data: { '1': [{ id: 7, score: 42 }] } }, () => { + window.lastClickedRowId = 7; + assert.equal(readFieldValue(1, -1, 'score'), 42); + }); +}); + +test('readVariableValue — lit gameData.variables par nom', () => { + withGameData({ variables: { points: { type: 'nombre_entier', value: '10' } } }, () => { + assert.equal(readVariableValue('points'), '10'); + assert.equal(readVariableValue('inconnue'), undefined); + }); +}); + +test('evaluateConditionClause — source "objet" (comportement historique)', () => { + withGameData({ data: { '1': [{ id: 5, pv: '3' }] } }, () => { + const clause = { cond_definition_id: 1, cond_row_id: 5, cond_field: 'pv', cond_operator: 'inferieur', cond_value: '10' }; + assert.equal(evaluateConditionClause(clause), true); + }); +}); + +test('evaluateConditionClause — source "variable"', () => { + withGameData({ variables: { pv: { type: 'nombre_entier', value: '3' } } }, () => { + const clause = { cond_source: 'variable', cond_variable: 'pv', cond_operator: 'inferieur', cond_value: '10' }; + assert.equal(evaluateConditionClause(clause), true); + }); +}); + +test('evaluateConditionNode — clause unique (pas de cond_clauses)', () => { + withGameData({ variables: { pv: { type: 'nombre_entier', value: '3' } } }, () => { + const node = { cond_source: 'variable', cond_variable: 'pv', cond_operator: 'egal', cond_value: '3' }; + assert.equal(evaluateConditionNode(node), true); + }); +}); + +test('evaluateConditionNode — clauses combinées ET (toutes vraies requises)', () => { + withGameData({ variables: { pv: { type: 'nombre_entier', value: '3' }, or: { type: 'nombre_entier', value: '0' } } }, () => { + const node = { + cond_combinator: 'et', + cond_clauses: JSON.stringify([ + { source: 'variable', variable: 'pv', operator: 'egal', value: '3' }, + { source: 'variable', variable: 'or', operator: 'egal', value: '0' }, + ]), + }; + assert.equal(evaluateConditionNode(node), true); + }); +}); + +test('evaluateConditionNode — clauses combinées OU (une seule vraie suffit)', () => { + withGameData({ variables: { pv: { type: 'nombre_entier', value: '3' }, or: { type: 'nombre_entier', value: '0' } } }, () => { + const node = { + cond_combinator: 'ou', + cond_clauses: JSON.stringify([ + { source: 'variable', variable: 'pv', operator: 'egal', value: '999' }, + { source: 'variable', variable: 'or', operator: 'egal', value: '0' }, + ]), + }; + assert.equal(evaluateConditionNode(node), true); + }); +}); diff --git a/static/js/play/actions.js b/static/js/play/actions.js new file mode 100644 index 00000000..220f8a8c --- /dev/null +++ b/static/js/play/actions.js @@ -0,0 +1,193 @@ +// ---------- Exécution des actions ---------- +// Extrait de templates/play.html (voir plan de modularisation) : +// application d'une propriété d'élément ("Modifier un élément") et +// exécution de chaque type de nœud Action. Les URLs de routes serveur +// (flow_node_run_data/run_variable) sont injectées par play.html dans +// window.FORGE_PLAY_URLS (données Jinja, jamais codées en dur ici). + +// Propriété CSS réelle + éventuel suffixe (unité) pour chaque propriété +// pilotable par une action "Modifier un élément". +var ELEMENT_PROPERTY_CSS = { + bg_color: { css: 'backgroundColor', suffix: '' }, + border_color: { css: 'borderColor', suffix: '' }, + width: { css: 'width', suffix: '%' }, + height: { css: 'height', suffix: '%' }, +}; + +function applyElementProperty(targetEl, property, value) { + if (property === 'contenu') { + // Voir ELEMENT_ACTION_PROPERTIES ("contenu") : texte littéral, pas de + // binding vers un champ d'objet ici (contrairement à + // applyOpenRowBindings, propre à "Ouvrir la ligne cliquée"). Comme + // pour les autres réglages ci-dessous, cible n'importe quel élément — + // pointer ceci vers un conteneur remplacerait tout son contenu + // imbriqué, à éviter. + targetEl.textContent = value == null ? '' : value; + return; + } + if (property === 'surbrillance') { + // 3.3 (Confort) — surbrillance générique dynamique : un liseré qui + // clignote (voir la classe .forgeHighlight et son animation dans le + //