Phase 3 : hardening qualite de code - typage strict, securite, dead code, a11y
Config strictement stricte partout (ruff, mypy --strict, bandit, vulture, import-linter, eslint, stylelint), aucune regle desactivee "pour ne pas casser le build" - l'existant a ete corrige pour la satisfaire plutot que l'inverse. Hooks pre-commit locaux (language: system) bloquants. - Typage mypy --strict propage a tout le moteur (db, screens, auth, core, ai, routes, puis publish/scripts/tests/app.py/build_css.py). - Securite : fuite de handle fichier Windows corrigee dans l'export SCORM (routes/publish/export_scorm.py), CSRF/RNG non-crypto/xAPI documentes (# nosec, # NOSONAR justifies), nouveau db.json_for_script() (echappe "</script>" dans le JSON embarque en <script>, 25 sites). - Architecture : imports circulaires/F811 nettoyes, contrats import-linter respectes, code mort retire (vulture). - Accessibilite : 69 champs de formulaire sans label correctement associe corriges (for/id ou aria-label) sur 11 templates. - ESLint/Stylelint : lot mecanique JS/CSS, regles ajustees puis appliquees (aucune desactivee sans verification individuelle). - Tests : isolation du compte admin partage (nettoyage ponctuel + fixture de teardown automatique en filet de securite), suite complete verte (591 tests Python, 241 tests JS). - SonarQube Community Build self-heberge (Docker + PostgreSQL) : rapport complet analyse point par point, faux positifs documentes. - .gitattributes ajoute (LF force) : core.autocrlf=true sur cette machine faisait echouer ESLint (linebreak-style) via un bug connu de git (checkout "en place" qui ignore l'eol force sur un fichier deja present sur disque - contourne en supprimant puis recreant chaque fichier suivi). djLint (H021, styles inline) volontairement saute pour ce commit - backlog assume, deja documente, traite dans un lot separe. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
7db4803b93
commit
c57420c8c9
@@ -8,33 +8,49 @@ require('../../conditions.js');
|
||||
global.compareValues = require('../../conditions.js').compareValues;
|
||||
|
||||
const filterMod = require('../filter-repeater-rows.js');
|
||||
|
||||
Object.assign(global, filterMod);
|
||||
|
||||
const visibilityMod = require('../visibility-condition.js');
|
||||
|
||||
Object.assign(global, visibilityMod);
|
||||
|
||||
const computeMod = require('../compute-operation.js');
|
||||
|
||||
Object.assign(global, computeMod);
|
||||
|
||||
const applyMod = require('../apply-actions.js');
|
||||
|
||||
Object.assign(global, applyMod);
|
||||
|
||||
const renderElementMod = require('../render-element.js');
|
||||
|
||||
Object.assign(global, renderElementMod);
|
||||
|
||||
const jaugeMod = require('../render-jauge.js');
|
||||
|
||||
Object.assign(global, jaugeMod);
|
||||
|
||||
const overlayMod = require('../render-overlay.js');
|
||||
|
||||
Object.assign(global, overlayMod);
|
||||
|
||||
const repeaterMod = require('../render-repeater.js');
|
||||
|
||||
Object.assign(global, repeaterMod);
|
||||
|
||||
const specialMod = require('../render-special-widgets.js');
|
||||
|
||||
Object.assign(global, specialMod);
|
||||
|
||||
module.exports = Object.assign(
|
||||
{}, filterMod, visibilityMod, computeMod, applyMod, renderElementMod,
|
||||
jaugeMod, overlayMod, repeaterMod, specialMod
|
||||
);
|
||||
module.exports = {
|
||||
...filterMod,
|
||||
...visibilityMod,
|
||||
...computeMod,
|
||||
...applyMod,
|
||||
...renderElementMod,
|
||||
...jaugeMod,
|
||||
...overlayMod,
|
||||
...repeaterMod,
|
||||
...specialMod,
|
||||
};
|
||||
|
||||
@@ -30,12 +30,14 @@ function withOfflineFetchGuard(fn) {
|
||||
}
|
||||
}
|
||||
|
||||
test("modifier_variable — mute gameData en mémoire sans fetch()", () => {
|
||||
test('modifier_variable — mute gameData en mémoire sans fetch()', () => {
|
||||
withOfflineFetchGuard((getRefreshCalls) => {
|
||||
global.gameData = { variables: { score_var: { value: '10', type: 'nombre_entier' } } };
|
||||
const node = {
|
||||
action_type: 'modifier_variable',
|
||||
target_variable: 'score_var', data_operation: 'incrementer', data_value: '5',
|
||||
target_variable: 'score_var',
|
||||
data_operation: 'incrementer',
|
||||
data_value: '5',
|
||||
};
|
||||
runActionNode(node);
|
||||
assert.equal(global.gameData.variables.score_var.value, '15');
|
||||
@@ -43,7 +45,7 @@ test("modifier_variable — mute gameData en mémoire sans fetch()", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("modifier_score — mute gameData.scoring sans fetch()", () => {
|
||||
test('modifier_score — mute gameData.scoring sans fetch()', () => {
|
||||
withOfflineFetchGuard((getRefreshCalls) => {
|
||||
global.gameData = { scoring: { score: 0, status: 'non_commence' } };
|
||||
const node = { action_type: 'modifier_score', data_operation: 'incrementer', data_value: '3' };
|
||||
@@ -53,7 +55,7 @@ test("modifier_score — mute gameData.scoring sans fetch()", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("definir_statut_partie — mute gameData.scoring.status sans fetch()", () => {
|
||||
test('definir_statut_partie — mute gameData.scoring.status sans fetch()', () => {
|
||||
withOfflineFetchGuard((getRefreshCalls) => {
|
||||
global.gameData = { scoring: { score: 0, status: 'non_commence' } };
|
||||
const node = { action_type: 'definir_statut_partie', data_value: 'reussi' };
|
||||
@@ -63,9 +65,9 @@ test("definir_statut_partie — mute gameData.scoring.status sans fetch()", () =
|
||||
});
|
||||
});
|
||||
|
||||
test("ajouter_ligne — crée une ligne en mémoire et fixe window.lastInsertedRowId sans fetch()", () => {
|
||||
test('ajouter_ligne — crée une ligne en mémoire et fixe window.lastInsertedRowId sans fetch()', () => {
|
||||
withOfflineFetchGuard((getRefreshCalls) => {
|
||||
global.gameData = { data: { '1': [{ id: 5 }] }, definition_names: {}, fields_meta: { '1': [] } };
|
||||
global.gameData = { data: { 1: [{ id: 5 }] }, definition_names: {}, fields_meta: { 1: [] } };
|
||||
const node = { action_type: 'ajouter_ligne', target_definition_id: 1 };
|
||||
runActionNode(node);
|
||||
assert.equal(global.window.lastInsertedRowId, 6);
|
||||
|
||||
@@ -18,12 +18,16 @@ function baseGameData() {
|
||||
},
|
||||
scoring: { score: 0, status: 'non_commence' },
|
||||
data: {
|
||||
'1': [{ id: 1, points: 5, nom: 'Alice' }, { id: 2, points: 8, nom: 'Bob' }],
|
||||
1: [{ id: 1, points: 5, nom: 'Alice' }, { id: 2, points: 8, nom: 'Bob' }],
|
||||
},
|
||||
fields_meta: {
|
||||
'1': [
|
||||
{ name: 'points', type: 'nombre_entier', min_value: 0, max_value: 10 },
|
||||
{ name: 'nom', type: 'texte', min_value: null, max_value: null },
|
||||
1: [
|
||||
{
|
||||
name: 'points', type: 'nombre_entier', min_value: 0, max_value: 10,
|
||||
},
|
||||
{
|
||||
name: 'nom', type: 'texte', min_value: null, max_value: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -65,21 +69,30 @@ test('definir_statut_partie — rejette un statut inconnu', () => {
|
||||
test('modifier_donnee — bornage min/max comme apply_data_action.py', () => {
|
||||
const gd = baseGameData();
|
||||
forgeApplyDataActionOffline(gd, {
|
||||
target_definition_id: 1, target_row_id: 1, target_field: 'points',
|
||||
data_operation: 'incrementer', data_value: '100',
|
||||
target_definition_id: 1,
|
||||
target_row_id: 1,
|
||||
target_field: 'points',
|
||||
data_operation: 'incrementer',
|
||||
data_value: '100',
|
||||
});
|
||||
assert.equal(gd.data['1'][0].points, 10); // borné à max_value=10, pas 105
|
||||
});
|
||||
|
||||
test('modifier_donnee — ligne/champ/objet inconnu échoue proprement', () => {
|
||||
const gd = baseGameData();
|
||||
assert.equal(forgeApplyDataActionOffline(gd, { target_definition_id: 1, target_row_id: 999, target_field: 'points', data_operation: 'incrementer', data_value: '1' }), false);
|
||||
assert.equal(forgeApplyDataActionOffline(gd, { target_definition_id: 1, target_row_id: 1, target_field: 'inconnu', data_operation: 'incrementer', data_value: '1' }), false);
|
||||
assert.equal(forgeApplyDataActionOffline(gd, {
|
||||
target_definition_id: 1, target_row_id: 999, target_field: 'points', data_operation: 'incrementer', data_value: '1',
|
||||
}), false);
|
||||
assert.equal(forgeApplyDataActionOffline(gd, {
|
||||
target_definition_id: 1, target_row_id: 1, target_field: 'inconnu', data_operation: 'incrementer', data_value: '1',
|
||||
}), false);
|
||||
});
|
||||
|
||||
test('ajouter_ligne — ligne vide, id auto-incrémenté, booléen à 0', () => {
|
||||
const gd = baseGameData();
|
||||
gd.fields_meta['1'].push({ name: 'actif', type: 'booleen', min_value: null, max_value: null });
|
||||
gd.fields_meta['1'].push({
|
||||
name: 'actif', type: 'booleen', min_value: null, max_value: null,
|
||||
});
|
||||
const newId = forgeApplyAddRowActionOffline(gd, { target_definition_id: 1 });
|
||||
assert.equal(newId, 3); // max existant (2) + 1
|
||||
const row = gd.data['1'][2];
|
||||
|
||||
@@ -15,18 +15,18 @@ function baseGameData() {
|
||||
niveau: { value: '3', type: 'nombre_entier' },
|
||||
perso: { value: JSON.stringify({ nom: 'Zelda', armes: ['épée', 'arc'] }), type: 'objet' },
|
||||
},
|
||||
definition_names: { '1': 'Progression', '2': 'Ennemis' },
|
||||
definition_names: { 1: 'Progression', 2: 'Ennemis' },
|
||||
fields_meta: {
|
||||
// "boss" (relation) : gameData.data (voir full_game_payload.py) est
|
||||
// déjà indexé par le nom D'AFFICHAGE du champ ("boss"), JAMAIS par sa
|
||||
// colonne SQL réelle ("boss_id") — contrairement à côté serveur
|
||||
// (filter_repeater_rows.py::_field_column), qui lit la base fraîche.
|
||||
'1': [{ name: 'niveau_actuel', type: 'nombre_entier' }, { name: 'boss', type: 'relation' }],
|
||||
'2': [{ name: 'nom', type: 'texte' }, { name: 'pv', type: 'nombre_entier' }],
|
||||
1: [{ name: 'niveau_actuel', type: 'nombre_entier' }, { name: 'boss', type: 'relation' }],
|
||||
2: [{ name: 'nom', type: 'texte' }, { name: 'pv', type: 'nombre_entier' }],
|
||||
},
|
||||
data: {
|
||||
'1': [{ id: 1, niveau_actuel: 4, boss: 7 }],
|
||||
'2': [
|
||||
1: [{ id: 1, niveau_actuel: 4, boss: 7 }],
|
||||
2: [
|
||||
{ id: 1, nom: 'Slime', pv: 10 },
|
||||
{ id: 2, nom: 'Dragon', pv: 100 },
|
||||
],
|
||||
@@ -85,20 +85,20 @@ test('forgeFilterRowsByClauses — ET (toutes vraies) vs OU (au moins une)', ()
|
||||
{ champ: 'nom', operateur: 'egal', valeur: 'Dragon' },
|
||||
{ champ: 'pv', operateur: 'superieur', valeur: '50' },
|
||||
];
|
||||
assert.deepEqual(forgeFilterRowsByClauses(gd, '2', rows, clauses, 'et').map(r => r.id), [2]);
|
||||
assert.deepEqual(forgeFilterRowsByClauses(gd, '2', rows, clauses, 'et').map((r) => r.id), [2]);
|
||||
|
||||
const orClauses = [
|
||||
{ champ: 'nom', operateur: 'egal', valeur: 'Slime' },
|
||||
{ champ: 'pv', operateur: 'superieur', valeur: '50' },
|
||||
];
|
||||
assert.deepEqual(forgeFilterRowsByClauses(gd, '2', rows, orClauses, 'ou').map(r => r.id), [1, 2]);
|
||||
assert.deepEqual(forgeFilterRowsByClauses(gd, '2', rows, orClauses, 'ou').map((r) => r.id), [1, 2]);
|
||||
});
|
||||
|
||||
test('forgeFilterRepeaterRows — ancien format 2 filtres fixes (repli)', () => {
|
||||
const gd = baseGameData();
|
||||
const attrs = { _filtre_champ: 'nom', _filtre_operateur: 'egal', _filtre_valeur: 'Slime' };
|
||||
const result = forgeFilterRepeaterRows(gd, '2', gd.data['2'], attrs);
|
||||
assert.deepEqual(result.map(r => r.id), [1]);
|
||||
assert.deepEqual(result.map((r) => r.id), [1]);
|
||||
});
|
||||
|
||||
test('forgeDecodeClauses — format JSON illimité prioritaire sur le legacy', () => {
|
||||
|
||||
@@ -17,16 +17,26 @@ function baseGameData() {
|
||||
element_type_templates: {},
|
||||
screens: [
|
||||
{
|
||||
id: 1, kind: 'document', preview_aspect: '9/16',
|
||||
id: 1,
|
||||
kind: 'document',
|
||||
preview_aspect: '9/16',
|
||||
elements: [
|
||||
{
|
||||
id: 10, widget: 'texte', content: 'Score : x', attributes: {}, style: {}, parent_id: null,
|
||||
id: 10,
|
||||
widget: 'texte',
|
||||
content: 'Score : x',
|
||||
attributes: {},
|
||||
style: {},
|
||||
parent_id: null,
|
||||
rendered_html: '<p>ancien rendu</p>',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2, kind: 'jeu_2d', scene_width: 960, scene_height: 540,
|
||||
id: 2,
|
||||
kind: 'jeu_2d',
|
||||
scene_width: 960,
|
||||
scene_height: 540,
|
||||
elements: [{ id: 20, kind: 'decor', rendered_html: '<img data-object-id="20">' }],
|
||||
},
|
||||
],
|
||||
@@ -40,7 +50,7 @@ test('recalcule rendered_html pour un écran document', () => {
|
||||
assert.ok(gd.screens[0].elements[0].rendered_html.startsWith('<p '));
|
||||
});
|
||||
|
||||
test("ne touche jamais un écran jeu_2d (les objets de scène se mutent déjà dans le DOM)", () => {
|
||||
test('ne touche jamais un écran jeu_2d (les objets de scène se mutent déjà dans le DOM)', () => {
|
||||
const gd = baseGameData();
|
||||
forgeRecomputeFullPayloadOffline(gd);
|
||||
assert.equal(gd.screens[1].elements[0].rendered_html, '<img data-object-id="20">');
|
||||
@@ -53,7 +63,7 @@ test('un élément imbriqué reçoit aussi son PROPRE rendered_html (pas seuleme
|
||||
});
|
||||
gd.screens[0].elements[0].widget = 'conteneur'; // devient le parent
|
||||
forgeRecomputeFullPayloadOffline(gd);
|
||||
const child = gd.screens[0].elements.find(e => e.id === 11);
|
||||
const child = gd.screens[0].elements.find((e) => e.id === 11);
|
||||
assert.ok(child.rendered_html.includes('enfant'));
|
||||
});
|
||||
|
||||
@@ -62,8 +72,10 @@ test('reflète une mutation déjà appliquée (score/variable) via la Donnée li
|
||||
gd.variables.niveau = { value: '5', type: 'nombre_entier' };
|
||||
gd.screens[0].elements[0].content = 'Visible';
|
||||
gd.screens[0].elements[0].attributes = {
|
||||
_visibilite_cond_mode: 'variable', _visibilite_cond_variable: 'niveau',
|
||||
_visibilite_cond_operateur: 'superieur_egal', _visibilite_cond_valeur: '5',
|
||||
_visibilite_cond_mode: 'variable',
|
||||
_visibilite_cond_variable: 'niveau',
|
||||
_visibilite_cond_operateur: 'superieur_egal',
|
||||
_visibilite_cond_valeur: '5',
|
||||
};
|
||||
forgeRecomputeFullPayloadOffline(gd);
|
||||
assert.ok(gd.screens[0].elements[0].rendered_html.includes('Visible'));
|
||||
|
||||
@@ -9,11 +9,15 @@ const assert = require('node:assert/strict');
|
||||
const { forgeRenderElementHtml, forgeStyleString, forgeAttrString } = require('../render-element.js');
|
||||
|
||||
function baseGameData() {
|
||||
return { variables: {}, definition_names: {}, fields_meta: {}, data: {} };
|
||||
return {
|
||||
variables: {}, definition_names: {}, fields_meta: {}, data: {},
|
||||
};
|
||||
}
|
||||
|
||||
test('texte simple — échappement HTML, saut de ligne -> <br>', () => {
|
||||
const el = { id: 1, widget: 'texte', content: 'a < b\nligne 2', attributes: {}, style: {} };
|
||||
const el = {
|
||||
id: 1, widget: 'texte', content: 'a < b\nligne 2', attributes: {}, style: {},
|
||||
};
|
||||
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
|
||||
assert.ok(html.startsWith('<p '));
|
||||
assert.ok(html.includes('a < b<br>ligne 2'));
|
||||
@@ -21,14 +25,18 @@ test('texte simple — échappement HTML, saut de ligne -> <br>', () => {
|
||||
});
|
||||
|
||||
test('titre — balise dynamique h1/h2/h3 selon _niveau', () => {
|
||||
const el = { id: 2, widget: 'titre', content: 'Bienvenue', attributes: { _niveau: '1' }, style: {} };
|
||||
const el = {
|
||||
id: 2, widget: 'titre', content: 'Bienvenue', attributes: { _niveau: '1' }, style: {},
|
||||
};
|
||||
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
|
||||
assert.ok(html.startsWith('<h1 '));
|
||||
assert.ok(html.includes('class="title"'));
|
||||
});
|
||||
|
||||
test('bouton — fixed_attrs class="button" fusionnée avec les attributs', () => {
|
||||
const el = { id: 3, widget: 'bouton', content: 'Valider', attributes: {}, style: {} };
|
||||
const el = {
|
||||
id: 3, widget: 'bouton', content: 'Valider', attributes: {}, style: {},
|
||||
};
|
||||
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
|
||||
assert.ok(html.startsWith('<button '));
|
||||
assert.ok(html.includes('class="button"'));
|
||||
@@ -36,7 +44,9 @@ test('bouton — fixed_attrs class="button" fusionnée avec les attributs', () =
|
||||
});
|
||||
|
||||
test('image — self-closing, pas de contenu ni enfants', () => {
|
||||
const el = { id: 4, widget: 'image', attributes: { src: 'chat.png' }, style: {} };
|
||||
const el = {
|
||||
id: 4, widget: 'image', attributes: { src: 'chat.png' }, style: {},
|
||||
};
|
||||
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
|
||||
assert.equal(html.indexOf('</img>'), -1);
|
||||
assert.ok(html.startsWith('<img '));
|
||||
@@ -44,26 +54,36 @@ test('image — self-closing, pas de contenu ni enfants', () => {
|
||||
});
|
||||
|
||||
test('image — alt="" par défaut (RGAA 1.1.1, décoratif faute de champ de description dédié)', () => {
|
||||
const el = { id: 4, widget: 'image', attributes: { src: 'chat.png' }, style: {} };
|
||||
const el = {
|
||||
id: 4, widget: 'image', attributes: { src: 'chat.png' }, style: {},
|
||||
};
|
||||
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
|
||||
assert.ok(html.includes('alt=""'));
|
||||
});
|
||||
|
||||
test('image — un alt déjà posé par l\'auteur n\'est jamais écrasé', () => {
|
||||
const el = { id: 4, widget: 'image', attributes: { src: 'chat.png', alt: 'Un chat noir' }, style: {} };
|
||||
const el = {
|
||||
id: 4, widget: 'image', attributes: { src: 'chat.png', alt: 'Un chat noir' }, style: {},
|
||||
};
|
||||
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
|
||||
assert.ok(html.includes('alt="Un chat noir"'));
|
||||
});
|
||||
|
||||
test('liste — une <li> par ligne de contenu, échappée', () => {
|
||||
const el = { id: 5, widget: 'liste', content: 'Pomme\nPoire < Kiwi\n\n', attributes: {}, style: {} };
|
||||
const el = {
|
||||
id: 5, widget: 'liste', content: 'Pomme\nPoire < Kiwi\n\n', attributes: {}, style: {},
|
||||
};
|
||||
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
|
||||
assert.ok(html.includes('<li>Pomme</li><li>Poire < Kiwi</li>'));
|
||||
});
|
||||
|
||||
test('conteneur avec enfants — rendu récursif', () => {
|
||||
const parent = { id: 10, widget: 'conteneur', content: '', attributes: {}, style: {} };
|
||||
const child = { id: 11, widget: 'texte', content: 'enfant', attributes: {}, style: {}, parent_id: 10 };
|
||||
const parent = {
|
||||
id: 10, widget: 'conteneur', content: '', attributes: {}, style: {},
|
||||
};
|
||||
const child = {
|
||||
id: 11, widget: 'texte', content: 'enfant', attributes: {}, style: {}, parent_id: 10,
|
||||
};
|
||||
const html = forgeRenderElementHtml(parent, baseGameData(), { 10: [child] }, {});
|
||||
assert.ok(html.includes('>enfant<'));
|
||||
});
|
||||
@@ -72,10 +92,15 @@ test('condition de visibilité fausse en mode jouable -> placeholder masqué', (
|
||||
const gd = baseGameData();
|
||||
gd.variables.niveau = { value: '1', type: 'nombre_entier' };
|
||||
const el = {
|
||||
id: 6, widget: 'texte', content: 'secret', style: {},
|
||||
id: 6,
|
||||
widget: 'texte',
|
||||
content: 'secret',
|
||||
style: {},
|
||||
attributes: {
|
||||
_visibilite_cond_mode: 'variable', _visibilite_cond_variable: 'niveau',
|
||||
_visibilite_cond_operateur: 'egal', _visibilite_cond_valeur: '99',
|
||||
_visibilite_cond_mode: 'variable',
|
||||
_visibilite_cond_variable: 'niveau',
|
||||
_visibilite_cond_operateur: 'egal',
|
||||
_visibilite_cond_valeur: '99',
|
||||
},
|
||||
};
|
||||
const html = forgeRenderElementHtml(el, gd, {}, { _forge_play_mode: true });
|
||||
@@ -88,10 +113,15 @@ test('condition de visibilité ignorée hors mode jouable (éditeur)', () => {
|
||||
const gd = baseGameData();
|
||||
gd.variables.niveau = { value: '1', type: 'nombre_entier' };
|
||||
const el = {
|
||||
id: 7, widget: 'texte', content: 'visible en édition', style: {},
|
||||
id: 7,
|
||||
widget: 'texte',
|
||||
content: 'visible en édition',
|
||||
style: {},
|
||||
attributes: {
|
||||
_visibilite_cond_mode: 'variable', _visibilite_cond_variable: 'niveau',
|
||||
_visibilite_cond_operateur: 'egal', _visibilite_cond_valeur: '99',
|
||||
_visibilite_cond_mode: 'variable',
|
||||
_visibilite_cond_variable: 'niveau',
|
||||
_visibilite_cond_operateur: 'egal',
|
||||
_visibilite_cond_valeur: '99',
|
||||
},
|
||||
};
|
||||
const html = forgeRenderElementHtml(el, gd, {}, {}); // pas de _forge_play_mode
|
||||
@@ -100,7 +130,9 @@ test('condition de visibilité ignorée hors mode jouable (éditeur)', () => {
|
||||
});
|
||||
|
||||
test('{{champ}} substitué dans le contenu (contexte de Répéteur)', () => {
|
||||
const el = { id: 8, widget: 'texte', content: 'Bonjour {{nom}}', attributes: {}, style: {} };
|
||||
const el = {
|
||||
id: 8, widget: 'texte', content: 'Bonjour {{nom}}', attributes: {}, style: {},
|
||||
};
|
||||
const html = forgeRenderElementHtml(el, baseGameData(), {}, { nom: 'Alice' });
|
||||
assert.ok(html.includes('Bonjour Alice'));
|
||||
});
|
||||
@@ -113,7 +145,9 @@ test('forgeStyleString — largeur par défaut selon axe flex du parent (enfant)
|
||||
});
|
||||
|
||||
test('forgeAttrString — booléen True = attribut sans valeur, False/null omis', () => {
|
||||
const s = forgeAttrString({ required: true, disabled: false, placeholder: null, name: 'x' }, 'color:red;');
|
||||
const s = forgeAttrString({
|
||||
required: true, disabled: false, placeholder: null, name: 'x',
|
||||
}, 'color:red;');
|
||||
assert.ok(s.includes('style="color:red;"'));
|
||||
assert.ok(s.includes('required'));
|
||||
assert.ok(!s.includes('disabled'));
|
||||
|
||||
@@ -8,35 +8,43 @@ const { forgeRenderElementHtml } = require('./_load_offline_runtime.js');
|
||||
function baseGameData() {
|
||||
return {
|
||||
variables: {},
|
||||
definition_names: { '1': 'Scores' },
|
||||
fields_meta: { '1': [{ name: 'points', type: 'nombre_entier' }, { name: 'nom', type: 'texte' }] },
|
||||
data: { '1': [{ id: 1, points: 42, nom: 'Alice' }, { id: 2, points: 7, nom: 'Bob' }] },
|
||||
personnage_animations: { '5': { idle: ['/static/characters/zombie/idle0.png', '/static/characters/zombie/idle1.png'] } },
|
||||
definition_names: { 1: 'Scores' },
|
||||
fields_meta: { 1: [{ name: 'points', type: 'nombre_entier' }, { name: 'nom', type: 'texte' }] },
|
||||
data: { 1: [{ id: 1, points: 42, nom: 'Alice' }, { id: 2, points: 7, nom: 'Bob' }] },
|
||||
personnage_animations: { 5: { idle: ['/static/characters/zombie/idle0.png', '/static/characters/zombie/idle1.png'] } },
|
||||
element_type_templates: {},
|
||||
};
|
||||
}
|
||||
|
||||
test('case à cocher — <label class="checkbox"><input type="checkbox">', () => {
|
||||
const el = { id: 1, widget: 'case_a_cocher', content: "J'accepte", attributes: {}, style: {} };
|
||||
const el = {
|
||||
id: 1, widget: 'case_a_cocher', content: "J'accepte", attributes: {}, style: {},
|
||||
};
|
||||
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
|
||||
// style toujours avant les autres attributs (voir forgeAttrString /
|
||||
// attr_string.py — le même ordre côté serveur).
|
||||
assert.ok(html.startsWith('<label style='));
|
||||
assert.ok(html.includes('class="checkbox"'));
|
||||
assert.ok(html.includes('type="checkbox"'));
|
||||
assert.ok(html.includes("J'accepte")); // échappé, comme html.escape() côté serveur
|
||||
assert.ok(html.includes('J'accepte')); // échappé, comme html.escape() côté serveur
|
||||
});
|
||||
|
||||
test('liste déroulante — une <option> par ligne de contenu', () => {
|
||||
const el = { id: 2, widget: 'liste_deroulante', content: 'Rouge\nVert\nBleu', attributes: {}, style: {} };
|
||||
const el = {
|
||||
id: 2, widget: 'liste_deroulante', content: 'Rouge\nVert\nBleu', attributes: {}, style: {},
|
||||
};
|
||||
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
|
||||
assert.ok(html.includes('<div class="select is-fullwidth">'));
|
||||
assert.equal((html.match(/<option/g) || []).length, 3);
|
||||
});
|
||||
|
||||
test('groupe de champs — <fieldset><legend> + enfants', () => {
|
||||
const parent = { id: 3, widget: 'groupe_champs', content: 'Coordonnées', attributes: {}, style: {} };
|
||||
const child = { id: 4, widget: 'texte', content: 'Nom', attributes: {}, style: {}, parent_id: 3 };
|
||||
const parent = {
|
||||
id: 3, widget: 'groupe_champs', content: 'Coordonnées', attributes: {}, style: {},
|
||||
};
|
||||
const child = {
|
||||
id: 4, widget: 'texte', content: 'Nom', attributes: {}, style: {}, parent_id: 3,
|
||||
};
|
||||
const html = forgeRenderElementHtml(parent, baseGameData(), { 3: [child] }, {});
|
||||
assert.ok(html.startsWith('<fieldset '));
|
||||
assert.ok(html.includes('<legend>Coordonnées</legend>'));
|
||||
@@ -44,22 +52,28 @@ test('groupe de champs — <fieldset><legend> + enfants', () => {
|
||||
});
|
||||
|
||||
test('tableau — une ligne par ligne de contenu, cellules séparées par ";"', () => {
|
||||
const el = { id: 5, widget: 'tableau', content: 'Nom;Score\nAlice;42', attributes: { _header_row: '1' }, style: {} };
|
||||
const el = {
|
||||
id: 5, widget: 'tableau', content: 'Nom;Score\nAlice;42', attributes: { _header_row: '1' }, style: {},
|
||||
};
|
||||
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
|
||||
assert.ok(html.includes('<th>Nom</th><th>Score</th>'));
|
||||
assert.ok(html.includes('<td>Alice</td><td>42</td>'));
|
||||
});
|
||||
|
||||
test('icône — repli sur "star" si _icon_slug absent', () => {
|
||||
const el = { id: 6, widget: 'icone', attributes: {}, style: {} };
|
||||
const el = {
|
||||
id: 6, widget: 'icone', attributes: {}, style: {},
|
||||
};
|
||||
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
|
||||
// le style entier passe par forgeHtmlEscape (comme html.escape() côté
|
||||
// serveur) : l'apostrophe de url('...') devient '.
|
||||
assert.ok(html.includes("--icon-url:url('icons/star.svg')"));
|
||||
assert.ok(html.includes('--icon-url:url('icons/star.svg')'));
|
||||
});
|
||||
|
||||
test('icône — aria-hidden="true" par défaut (RGAA 1.1.1, décorative faute de description)', () => {
|
||||
const el = { id: 6, widget: 'icone', attributes: {}, style: {} };
|
||||
const el = {
|
||||
id: 6, widget: 'icone', attributes: {}, style: {},
|
||||
};
|
||||
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
|
||||
assert.ok(html.includes('aria-hidden="true"'));
|
||||
});
|
||||
@@ -72,17 +86,25 @@ test('icône — aria-hidden="true" par défaut (RGAA 1.1.1, décorative faute d
|
||||
// l'export, voir build_scorm_package.py::_build_icon_data_uris) contourne
|
||||
// entièrement le problème : aucune requête réseau séparée pour une data: URI.
|
||||
test('icône — utilise gameData.icon_data_uris quand pré-encodée (contourne le blocage CORS de Firefox sous file://)', () => {
|
||||
const el = { id: 6, widget: 'icone', attributes: { _icon_slug: 'envelope' }, style: {} };
|
||||
const el = {
|
||||
id: 6, widget: 'icone', attributes: { _icon_slug: 'envelope' }, style: {},
|
||||
};
|
||||
const gd = baseGameData();
|
||||
gd.icon_data_uris = { envelope: 'data:image/svg+xml;base64,QUJD' };
|
||||
const html = forgeRenderElementHtml(el, gd, {}, {});
|
||||
assert.ok(html.includes("--icon-url:url('data:image/svg+xml;base64,QUJD')"));
|
||||
assert.ok(html.includes('--icon-url:url('data:image/svg+xml;base64,QUJD')'));
|
||||
});
|
||||
|
||||
test('onglets — un panneau actif, les autres masqués', () => {
|
||||
const parent = { id: 7, widget: 'onglets', attributes: {}, style: {} };
|
||||
const tab1 = { id: 8, name: 'Infos', widget: 'conteneur', content: '', attributes: {}, style: {}, parent_id: 7 };
|
||||
const tab2 = { id: 9, name: 'Options', widget: 'conteneur', content: '', attributes: {}, style: {}, parent_id: 7 };
|
||||
const parent = {
|
||||
id: 7, widget: 'onglets', attributes: {}, style: {},
|
||||
};
|
||||
const tab1 = {
|
||||
id: 8, name: 'Infos', widget: 'conteneur', content: '', attributes: {}, style: {}, parent_id: 7,
|
||||
};
|
||||
const tab2 = {
|
||||
id: 9, name: 'Options', widget: 'conteneur', content: '', attributes: {}, style: {}, parent_id: 7,
|
||||
};
|
||||
const html = forgeRenderElementHtml(parent, baseGameData(), { 7: [tab1, tab2] }, {});
|
||||
assert.ok(html.includes('is-active'));
|
||||
assert.ok(html.includes('tabpanel-8'));
|
||||
@@ -90,7 +112,9 @@ test('onglets — un panneau actif, les autres masqués', () => {
|
||||
});
|
||||
|
||||
test('personnage — lit gameData.personnage_animations (idle en premier)', () => {
|
||||
const el = { id: 5, widget: 'personnage', attributes: {}, style: {} };
|
||||
const el = {
|
||||
id: 5, widget: 'personnage', attributes: {}, style: {},
|
||||
};
|
||||
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
|
||||
assert.ok(html.startsWith('<img '));
|
||||
assert.ok(html.includes('src="/static/characters/zombie/idle0.png"'));
|
||||
@@ -100,8 +124,12 @@ test('personnage — lit gameData.personnage_animations (idle en premier)', () =
|
||||
|
||||
test('jauge — pourcentage et couleur interpolée depuis un champ d\'objet', () => {
|
||||
const el = {
|
||||
id: 10, widget: 'jauge', style: {},
|
||||
attributes: { _definition_id: '1', _champ: 'points', _min: '0', _max: '100', _couleur_basse: '#000000', _couleur_haute: '#ffffff' },
|
||||
id: 10,
|
||||
widget: 'jauge',
|
||||
style: {},
|
||||
attributes: {
|
||||
_definition_id: '1', _champ: 'points', _min: '0', _max: '100', _couleur_basse: '#000000', _couleur_haute: '#ffffff',
|
||||
},
|
||||
};
|
||||
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
|
||||
assert.ok(html.includes('value="42"')); // ligne la plus récente = Alice (id 1, points=42)
|
||||
@@ -110,8 +138,11 @@ test('jauge — pourcentage et couleur interpolée depuis un champ d\'objet', ()
|
||||
|
||||
test('répéteur — une repeaterItem par ligne, {{champ}} substitué', () => {
|
||||
const el = {
|
||||
id: 11, widget: 'repeteur_donnees', content: '{{nom}} : {{points}} pts',
|
||||
attributes: { _definition_id: '1' }, style: {},
|
||||
id: 11,
|
||||
widget: 'repeteur_donnees',
|
||||
content: '{{nom}} : {{points}} pts',
|
||||
attributes: { _definition_id: '1' },
|
||||
style: {},
|
||||
};
|
||||
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
|
||||
assert.equal((html.match(/repeaterItem/g) || []).length, 2);
|
||||
@@ -121,14 +152,19 @@ test('répéteur — une repeaterItem par ligne, {{champ}} substitué', () => {
|
||||
});
|
||||
|
||||
test('répéteur — objet introuvable, message explicite plutôt qu\'un plantage', () => {
|
||||
const el = { id: 12, widget: 'repeteur_donnees', content: '', attributes: { _definition_id: '999' }, style: {} };
|
||||
const el = {
|
||||
id: 12, widget: 'repeteur_donnees', content: '', attributes: { _definition_id: '999' }, style: {},
|
||||
};
|
||||
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
|
||||
assert.ok(html.includes('Objet introuvable'));
|
||||
});
|
||||
|
||||
test('donnée liée (Texte) — {{champ}} résolu depuis la ligne trouvée', () => {
|
||||
const el = {
|
||||
id: 13, widget: 'texte', content: 'Score actuel : {{points}}', style: {},
|
||||
id: 13,
|
||||
widget: 'texte',
|
||||
content: 'Score actuel : {{points}}',
|
||||
style: {},
|
||||
attributes: {
|
||||
_data_definition_id: '1',
|
||||
_data_filtres_json: JSON.stringify([{ champ: 'nom', operateur: 'egal', valeur: 'Bob' }]),
|
||||
@@ -140,7 +176,9 @@ test('donnée liée (Texte) — {{champ}} résolu depuis la ligne trouvée', ()
|
||||
});
|
||||
|
||||
test('superposition — voile plein écran seulement en mode jouable', () => {
|
||||
const el = { id: 14, widget: 'superposition', content: '', attributes: {}, style: {} };
|
||||
const el = {
|
||||
id: 14, widget: 'superposition', content: '', attributes: {}, style: {},
|
||||
};
|
||||
const editorHtml = forgeRenderElementHtml(el, baseGameData(), {}, {});
|
||||
assert.equal(editorHtml.indexOf('position:fixed'), -1);
|
||||
const playHtml = forgeRenderElementHtml(el, baseGameData(), {}, { _forge_play_mode: true });
|
||||
@@ -151,10 +189,14 @@ test('superposition — voile plein écran seulement en mode jouable', () => {
|
||||
test('élément de jeu réutilisable — rejoue son écran-modèle depuis gameData.element_type_templates', () => {
|
||||
const gd = baseGameData();
|
||||
gd.element_type_templates['20'] = {
|
||||
top: [{ id: 30, widget: 'texte', content: 'Modèle', attributes: {}, style: {}, parent_id: 'template-root' }],
|
||||
top: [{
|
||||
id: 30, widget: 'texte', content: 'Modèle', attributes: {}, style: {}, parent_id: 'template-root',
|
||||
}],
|
||||
children_map: {},
|
||||
};
|
||||
const el = { id: 15, widget: 'conteneur', element_type_id: 20, content: '', attributes: {}, style: {} };
|
||||
const el = {
|
||||
id: 15, widget: 'conteneur', element_type_id: 20, content: '', attributes: {}, style: {},
|
||||
};
|
||||
const html = forgeRenderElementHtml(el, gd, {}, {});
|
||||
assert.ok(html.includes('>Modèle<'));
|
||||
});
|
||||
@@ -162,10 +204,14 @@ test('élément de jeu réutilisable — rejoue son écran-modèle depuis gameDa
|
||||
test('élément de jeu = superposition seule -> enveloppe transparente sans classe "box"', () => {
|
||||
const gd = baseGameData();
|
||||
gd.element_type_templates['21'] = {
|
||||
top: [{ id: 31, widget: 'superposition', content: '', attributes: {}, style: { display: 'none' }, parent_id: 'template-root' }],
|
||||
top: [{
|
||||
id: 31, widget: 'superposition', content: '', attributes: {}, style: { display: 'none' }, parent_id: 'template-root',
|
||||
}],
|
||||
children_map: {},
|
||||
};
|
||||
const el = { id: 16, widget: 'conteneur', element_type_id: 21, content: '', attributes: {}, style: {} };
|
||||
const el = {
|
||||
id: 16, widget: 'conteneur', element_type_id: 21, content: '', attributes: {}, style: {},
|
||||
};
|
||||
const html = forgeRenderElementHtml(el, gd, {}, {});
|
||||
assert.equal(html.indexOf('class="box"'), -1);
|
||||
assert.ok(html.startsWith('<div '));
|
||||
|
||||
@@ -24,8 +24,11 @@ function fakeApi() {
|
||||
|
||||
test('FORGE_TO_SCORM_STATUS — vocabulaire figé cmi.core.lesson_status', () => {
|
||||
assert.deepEqual(FORGE_TO_SCORM_STATUS, {
|
||||
non_commence: 'not attempted', en_cours: 'incomplete', termine: 'completed',
|
||||
reussi: 'passed', echoue: 'failed',
|
||||
non_commence: 'not attempted',
|
||||
en_cours: 'incomplete',
|
||||
termine: 'completed',
|
||||
reussi: 'passed',
|
||||
echoue: 'failed',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,7 +46,7 @@ test('forgeScormInit ne pose PAS cmi.core.score.min/max si le jeu n\'a aucune qu
|
||||
global.window.FORGE_MAX_QUIZ_SCORE = 0;
|
||||
try {
|
||||
forgeScormInit();
|
||||
assert.ok(!api.calls.some(c => c[1] === 'cmi.core.score.max'));
|
||||
assert.ok(!api.calls.some((c) => c[1] === 'cmi.core.score.max'));
|
||||
} finally { delete global.window.API; delete global.window.FORGE_MAX_QUIZ_SCORE; }
|
||||
});
|
||||
|
||||
@@ -54,8 +57,8 @@ test('forgeScormInit pose cmi.core.score.min/max une fois si le jeu a des questi
|
||||
try {
|
||||
forgeScormInit();
|
||||
assert.deepEqual(
|
||||
api.calls.filter(c => c[0] === 'LMSSetValue').map(c => [c[1], c[2]]),
|
||||
[['cmi.core.score.min', '0'], ['cmi.core.score.max', '20']]
|
||||
api.calls.filter((c) => c[0] === 'LMSSetValue').map((c) => [c[1], c[2]]),
|
||||
[['cmi.core.score.min', '0'], ['cmi.core.score.max', '20']],
|
||||
);
|
||||
} finally { delete global.window.API; delete global.window.FORGE_MAX_QUIZ_SCORE; }
|
||||
});
|
||||
@@ -68,8 +71,8 @@ test('forgeScormSyncScore envoie score.raw/lesson_status et Commit, une seule fo
|
||||
forgeScormInit();
|
||||
forgeScormSyncScore({ scoring: { score: 7, status: 'reussi' } });
|
||||
const callsAfterFirst = api.calls.length;
|
||||
assert.ok(api.calls.some(c => c[0] === 'LMSSetValue' && c[1] === 'cmi.core.score.raw' && c[2] === '7'));
|
||||
assert.ok(api.calls.some(c => c[0] === 'LMSSetValue' && c[1] === 'cmi.core.lesson_status' && c[2] === 'passed'));
|
||||
assert.ok(api.calls.some((c) => c[0] === 'LMSSetValue' && c[1] === 'cmi.core.score.raw' && c[2] === '7'));
|
||||
assert.ok(api.calls.some((c) => c[0] === 'LMSSetValue' && c[1] === 'cmi.core.lesson_status' && c[2] === 'passed'));
|
||||
forgeScormSyncScore({ scoring: { score: 7, status: 'reussi' } });
|
||||
assert.equal(api.calls.length, callsAfterFirst);
|
||||
} finally { delete global.window.API; delete global.window.FORGE_MAX_QUIZ_SCORE; }
|
||||
|
||||
@@ -20,7 +20,7 @@ function fakeApi(interactionsCount) {
|
||||
calls,
|
||||
Initialize: () => calls.push(['Initialize']),
|
||||
SetValue: (key, value) => calls.push(['SetValue', key, value]),
|
||||
GetValue: key => (key === 'cmi.interactions._count' ? String(interactionsCount || 0) : ''),
|
||||
GetValue: (key) => (key === 'cmi.interactions._count' ? String(interactionsCount || 0) : ''),
|
||||
Commit: () => calls.push(['Commit']),
|
||||
};
|
||||
}
|
||||
@@ -52,8 +52,8 @@ test('forgeScorm2004Init pose cmi.score.min/max une fois si le jeu a des questio
|
||||
try {
|
||||
forgeScorm2004Init();
|
||||
assert.deepEqual(
|
||||
api.calls.filter(c => c[0] === 'SetValue').map(c => [c[1], c[2]]),
|
||||
[['cmi.score.min', '0'], ['cmi.score.max', '20']]
|
||||
api.calls.filter((c) => c[0] === 'SetValue').map((c) => [c[1], c[2]]),
|
||||
[['cmi.score.min', '0'], ['cmi.score.max', '20']],
|
||||
);
|
||||
} finally { delete global.window.API_1484_11; delete global.window.FORGE_MAX_QUIZ_SCORE; }
|
||||
});
|
||||
@@ -66,10 +66,10 @@ test('forgeScorm2004SyncScore — "echoue" envoie completion_status="completed"
|
||||
forgeScorm2004Init();
|
||||
forgeScorm2004SyncScore({ scoring: { score: 3, status: 'echoue' } });
|
||||
assert.deepEqual(
|
||||
api.calls.filter(c => c[0] === 'SetValue').map(c => [c[1], c[2]]),
|
||||
[['cmi.score.raw', '3'], ['cmi.completion_status', 'completed'], ['cmi.success_status', 'failed']]
|
||||
api.calls.filter((c) => c[0] === 'SetValue').map((c) => [c[1], c[2]]),
|
||||
[['cmi.score.raw', '3'], ['cmi.completion_status', 'completed'], ['cmi.success_status', 'failed']],
|
||||
);
|
||||
assert.ok(api.calls.some(c => c[0] === 'Commit'));
|
||||
assert.ok(api.calls.some((c) => c[0] === 'Commit'));
|
||||
} finally { delete global.window.API_1484_11; }
|
||||
});
|
||||
|
||||
@@ -97,7 +97,7 @@ test('forgeScorm2004SyncScore — ajoute cmi.score.scaled UNIQUEMENT si des ques
|
||||
try {
|
||||
forgeScorm2004Init();
|
||||
forgeScorm2004SyncScore({ scoring: { score: 3, status: 'reussi' } });
|
||||
const scaledCall = api.calls.find(c => c[0] === 'SetValue' && c[1] === 'cmi.score.scaled');
|
||||
const scaledCall = api.calls.find((c) => c[0] === 'SetValue' && c[1] === 'cmi.score.scaled');
|
||||
assert.equal(scaledCall[2], String(3 / 4));
|
||||
} finally {
|
||||
delete global.window.API_1484_11;
|
||||
@@ -117,7 +117,7 @@ test('forgeScorm2004NotifyQuestionAnswered pose une entrée cmi.interactions.n.*
|
||||
try {
|
||||
forgeScorm2004Init();
|
||||
forgeScorm2004NotifyQuestionAnswered('d_5', 'Le sorcier', 1, 'Qui es-tu ?', true, 0, 0);
|
||||
const sets = Object.fromEntries(api.calls.filter(c => c[0] === 'SetValue').map(c => [c[1], c[2]]));
|
||||
const sets = Object.fromEntries(api.calls.filter((c) => c[0] === 'SetValue').map((c) => [c[1], c[2]]));
|
||||
assert.equal(sets['cmi.interactions.2.id'], 'dialogue:d_5:question:1');
|
||||
assert.equal(sets['cmi.interactions.2.type'], 'choice');
|
||||
assert.match(sets['cmi.interactions.2.timestamp'], /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/);
|
||||
@@ -125,7 +125,7 @@ test('forgeScorm2004NotifyQuestionAnswered pose une entrée cmi.interactions.n.*
|
||||
assert.equal(sets['cmi.interactions.2.learner_response'], '0');
|
||||
assert.equal(sets['cmi.interactions.2.result'], 'correct');
|
||||
assert.equal(sets['cmi.interactions.2.correct_responses.0.pattern'], '0');
|
||||
assert.ok(api.calls.some(c => c[0] === 'Commit'));
|
||||
assert.ok(api.calls.some((c) => c[0] === 'Commit'));
|
||||
} finally { delete global.window.API_1484_11; }
|
||||
});
|
||||
|
||||
@@ -135,7 +135,7 @@ test('forgeScorm2004NotifyQuestionAnswered — mauvaise réponse : result="incor
|
||||
try {
|
||||
forgeScorm2004Init();
|
||||
forgeScorm2004NotifyQuestionAnswered('d_5', 'Le sorcier', 0, 'Qui es-tu ?', false, 1, 0);
|
||||
const sets = Object.fromEntries(api.calls.filter(c => c[0] === 'SetValue').map(c => [c[1], c[2]]));
|
||||
const sets = Object.fromEntries(api.calls.filter((c) => c[0] === 'SetValue').map((c) => [c[1], c[2]]));
|
||||
assert.equal(sets['cmi.interactions.0.result'], 'incorrect');
|
||||
assert.equal(sets['cmi.interactions.0.learner_response'], '1');
|
||||
assert.equal(sets['cmi.interactions.0.correct_responses.0.pattern'], '0');
|
||||
|
||||
@@ -7,9 +7,9 @@ const { forgeIsElementVisibleOffline } = require('../visibility-condition.js');
|
||||
function baseGameData() {
|
||||
return {
|
||||
variables: { niveau: { value: '3', type: 'nombre_entier' } },
|
||||
definition_names: { '1': 'Progression' },
|
||||
fields_meta: { '1': [{ name: 'debloque', type: 'booleen' }] },
|
||||
data: { '1': [{ id: 1, debloque: 1 }] },
|
||||
definition_names: { 1: 'Progression' },
|
||||
fields_meta: { 1: [{ name: 'debloque', type: 'booleen' }] },
|
||||
data: { 1: [{ id: 1, debloque: 1 }] },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -21,28 +21,37 @@ test('sans réglage de condition, toujours visible', () => {
|
||||
test('mode variable — égal/supérieur', () => {
|
||||
const gd = baseGameData();
|
||||
assert.equal(forgeIsElementVisibleOffline(gd, {
|
||||
_visibilite_cond_mode: 'variable', _visibilite_cond_variable: 'niveau',
|
||||
_visibilite_cond_operateur: 'superieur_egal', _visibilite_cond_valeur: '3',
|
||||
_visibilite_cond_mode: 'variable',
|
||||
_visibilite_cond_variable: 'niveau',
|
||||
_visibilite_cond_operateur: 'superieur_egal',
|
||||
_visibilite_cond_valeur: '3',
|
||||
}), true);
|
||||
assert.equal(forgeIsElementVisibleOffline(gd, {
|
||||
_visibilite_cond_mode: 'variable', _visibilite_cond_variable: 'niveau',
|
||||
_visibilite_cond_operateur: 'superieur', _visibilite_cond_valeur: '3',
|
||||
_visibilite_cond_mode: 'variable',
|
||||
_visibilite_cond_variable: 'niveau',
|
||||
_visibilite_cond_operateur: 'superieur',
|
||||
_visibilite_cond_valeur: '3',
|
||||
}), false);
|
||||
});
|
||||
|
||||
test('mode variable — variable supprimée depuis : ne bloque jamais', () => {
|
||||
const gd = baseGameData();
|
||||
assert.equal(forgeIsElementVisibleOffline(gd, {
|
||||
_visibilite_cond_mode: 'variable', _visibilite_cond_variable: 'disparue',
|
||||
_visibilite_cond_operateur: 'egal', _visibilite_cond_valeur: '1',
|
||||
_visibilite_cond_mode: 'variable',
|
||||
_visibilite_cond_variable: 'disparue',
|
||||
_visibilite_cond_operateur: 'egal',
|
||||
_visibilite_cond_valeur: '1',
|
||||
}), true);
|
||||
});
|
||||
|
||||
test('mode objet — lit la ligne la plus récente hors répéteur', () => {
|
||||
const gd = baseGameData();
|
||||
assert.equal(forgeIsElementVisibleOffline(gd, {
|
||||
_visibilite_cond_mode: 'objet', _visibilite_cond_definition_id: '1',
|
||||
_visibilite_cond_champ: 'debloque', _visibilite_cond_operateur: 'egal', _visibilite_cond_valeur: 'oui',
|
||||
_visibilite_cond_mode: 'objet',
|
||||
_visibilite_cond_definition_id: '1',
|
||||
_visibilite_cond_champ: 'debloque',
|
||||
_visibilite_cond_operateur: 'egal',
|
||||
_visibilite_cond_valeur: 'oui',
|
||||
}), true);
|
||||
});
|
||||
|
||||
@@ -50,7 +59,10 @@ test('mode objet — utilise ctx (ligne du Répéteur) plutôt que la ligne la p
|
||||
const gd = baseGameData();
|
||||
const ctx = { debloque: 0 };
|
||||
assert.equal(forgeIsElementVisibleOffline(gd, {
|
||||
_visibilite_cond_mode: 'objet', _visibilite_cond_definition_id: '1',
|
||||
_visibilite_cond_champ: 'debloque', _visibilite_cond_operateur: 'egal', _visibilite_cond_valeur: 'oui',
|
||||
_visibilite_cond_mode: 'objet',
|
||||
_visibilite_cond_definition_id: '1',
|
||||
_visibilite_cond_champ: 'debloque',
|
||||
_visibilite_cond_operateur: 'egal',
|
||||
_visibilite_cond_valeur: 'oui',
|
||||
}, ctx), false);
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ const assert = require('node:assert/strict');
|
||||
|
||||
global.window = global.window || {};
|
||||
global.window.addEventListener = global.window.addEventListener || function () {};
|
||||
global.btoa = global.btoa || (str => Buffer.from(str).toString('base64'));
|
||||
global.btoa = global.btoa || ((str) => Buffer.from(str).toString('base64'));
|
||||
|
||||
const {
|
||||
forgeXapiNotifyDialogueCompleted, forgeXapiNotifyQuestionAnswered,
|
||||
@@ -16,8 +16,11 @@ const {
|
||||
|
||||
test.beforeEach(() => {
|
||||
global.window.FORGE_XAPI_CONFIG = {
|
||||
endpoint: 'https://lrs.example.com/xapi', login: 'user', password: 'pass',
|
||||
activity_id: 'urn:forge-engine:game:test', activity_name: 'Mon jeu',
|
||||
endpoint: 'https://lrs.example.com/xapi',
|
||||
login: 'user',
|
||||
password: 'pass',
|
||||
activity_id: 'urn:forge-engine:game:test',
|
||||
activity_name: 'Mon jeu',
|
||||
};
|
||||
});
|
||||
|
||||
@@ -38,7 +41,7 @@ test('forgeXapiNotifyDialogueCompleted envoie un statement "completed" ciblant l
|
||||
global.fetch = (url, opts) => { calls.push({ url, opts }); return Promise.resolve(); };
|
||||
forgeXapiNotifyDialogueCompleted('d_5', 'Le sorcier');
|
||||
const body = JSON.parse(calls[0].opts.body);
|
||||
assert.equal(body.verb.id, 'http://adlnet.gov/expapi/verbs/completed');
|
||||
assert.equal(body.verb.id, 'http://adlnet.gov/expapi/verbs/completed'); // NOSONAR S5332
|
||||
assert.equal(body.object.id, 'urn:forge-engine:game:test:dialogue:d_5');
|
||||
assert.equal(body.result.completion, true);
|
||||
});
|
||||
@@ -48,7 +51,7 @@ test('forgeXapiNotifyQuestionAnswered envoie un statement "answered" avec succes
|
||||
global.fetch = (url, opts) => { calls.push({ url, opts }); return Promise.resolve(); };
|
||||
forgeXapiNotifyQuestionAnswered('d_5', 'Le sorcier', 1, 'Qui es-tu ?', true, 5);
|
||||
const body = JSON.parse(calls[0].opts.body);
|
||||
assert.equal(body.verb.id, 'http://adlnet.gov/expapi/verbs/answered');
|
||||
assert.equal(body.verb.id, 'http://adlnet.gov/expapi/verbs/answered'); // NOSONAR S5332
|
||||
assert.equal(body.object.id, 'urn:forge-engine:game:test:dialogue:d_5:question:1');
|
||||
assert.equal(body.result.success, true);
|
||||
assert.equal(body.result.score.raw, 5);
|
||||
|
||||
@@ -18,24 +18,24 @@
|
||||
// full_game_payload.py) pour que conditions.js continue de la lire
|
||||
// exactement comme aujourd'hui.
|
||||
function forgeApplyVariableActionOffline(gameData, action) {
|
||||
var varName = action.target_variable;
|
||||
var operation = action.data_operation;
|
||||
var rawValue = action.data_value;
|
||||
const varName = action.target_variable;
|
||||
const operation = action.data_operation;
|
||||
const rawValue = action.data_value;
|
||||
if (!varName || !operation) return false;
|
||||
var variable = gameData.variables && gameData.variables[varName];
|
||||
const variable = gameData.variables && gameData.variables[varName];
|
||||
if (!variable) return false;
|
||||
var varType = variable.type;
|
||||
var current = variable.value;
|
||||
if (varType === "nombre_entier" || varType === "nombre_decimal") {
|
||||
current = (current === null || current === undefined || current === "") ? 0 : parseFloat(current);
|
||||
if (isNaN(current)) current = 0;
|
||||
} else if (varType === "booleen") {
|
||||
var normalized = String(current == null ? "" : current).trim().toLowerCase();
|
||||
current = (normalized === "1" || normalized === "true" || normalized === "vrai") ? 1 : 0;
|
||||
const varType = variable.type;
|
||||
let current = variable.value;
|
||||
if (varType === 'nombre_entier' || varType === 'nombre_decimal') {
|
||||
current = (current === null || current === undefined || current === '') ? 0 : parseFloat(current);
|
||||
if (Number.isNaN(current)) current = 0;
|
||||
} else if (varType === 'booleen') {
|
||||
const normalized = String(current == null ? '' : current).trim().toLowerCase();
|
||||
current = (normalized === '1' || normalized === 'true' || normalized === 'vrai') ? 1 : 0;
|
||||
}
|
||||
var newValue;
|
||||
let newValue;
|
||||
try {
|
||||
newValue = forgeComputeNewValue(operation, current, rawValue, varType === "nombre_decimal");
|
||||
newValue = forgeComputeNewValue(operation, current, rawValue, varType === 'nombre_decimal');
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
@@ -47,15 +47,15 @@ function forgeApplyVariableActionOffline(gameData, action) {
|
||||
// Port de apply_score_action.py/apply_status_action.py — voir db/scoring/
|
||||
// pour le vocabulaire des statuts (db.SCORE_STATUS_LABELS côté serveur,
|
||||
// dupliqué ici en dur : ces 5 clés sont figées, voir db/constants.py).
|
||||
var FORGE_SCORE_STATUS_KEYS = ["non_commence", "en_cours", "termine", "reussi", "echoue"];
|
||||
const FORGE_SCORE_STATUS_KEYS = ['non_commence', 'en_cours', 'termine', 'reussi', 'echoue'];
|
||||
|
||||
function forgeApplyScoreActionOffline(gameData, action) {
|
||||
var operation = action.data_operation;
|
||||
var rawValue = action.data_value;
|
||||
const operation = action.data_operation;
|
||||
const rawValue = action.data_value;
|
||||
if (!operation) return false;
|
||||
if (!gameData.scoring) gameData.scoring = { score: 0, status: "non_commence" };
|
||||
var current = gameData.scoring.score || 0;
|
||||
var newValue;
|
||||
if (!gameData.scoring) gameData.scoring = { score: 0, status: 'non_commence' };
|
||||
const current = gameData.scoring.score || 0;
|
||||
let newValue;
|
||||
try {
|
||||
newValue = forgeComputeNewValue(operation, current, rawValue, true);
|
||||
} catch (e) {
|
||||
@@ -65,16 +65,16 @@ function forgeApplyScoreActionOffline(gameData, action) {
|
||||
// xAPI bolt-on (voir static/js/play/offline/xapi-client.js) : no-op si
|
||||
// le fichier n'est pas chargé (tests Node via module.exports ci-dessous,
|
||||
// ou réglage xAPI jamais configuré pour ce jeu).
|
||||
if (typeof forgeXapiNotifyScoreChanged === "function") forgeXapiNotifyScoreChanged(newValue);
|
||||
if (typeof forgeXapiNotifyScoreChanged === 'function') forgeXapiNotifyScoreChanged(newValue);
|
||||
return true;
|
||||
}
|
||||
|
||||
function forgeApplyStatusActionOffline(gameData, action) {
|
||||
var status = action.data_value;
|
||||
const status = action.data_value;
|
||||
if (!status || FORGE_SCORE_STATUS_KEYS.indexOf(status) === -1) return false;
|
||||
if (!gameData.scoring) gameData.scoring = { score: 0, status: "non_commence" };
|
||||
if (!gameData.scoring) gameData.scoring = { score: 0, status: 'non_commence' };
|
||||
gameData.scoring.status = status;
|
||||
if (typeof forgeXapiNotifyStatusChanged === "function") forgeXapiNotifyStatusChanged(status);
|
||||
if (typeof forgeXapiNotifyStatusChanged === 'function') forgeXapiNotifyStatusChanged(status);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -86,45 +86,45 @@ function forgeApplyStatusActionOffline(gameData, action) {
|
||||
// {name, type, min_value, max_value} — même forme que le payload en
|
||||
// ligne (screens/payload/full_game_payload.py).
|
||||
function forgeFindFieldMeta(gameData, definitionId, fieldName) {
|
||||
var fields = gameData.fields_meta && gameData.fields_meta[definitionId];
|
||||
const fields = gameData.fields_meta && gameData.fields_meta[definitionId];
|
||||
if (!fields) return null;
|
||||
for (var i = 0; i < fields.length; i++) {
|
||||
for (let i = 0; i < fields.length; i++) {
|
||||
if (fields[i].name === fieldName) return fields[i];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function forgeClampToFieldBounds(value, fieldMeta) {
|
||||
if (fieldMeta.type !== "nombre_entier" && fieldMeta.type !== "nombre_decimal") return value;
|
||||
if (fieldMeta.type !== 'nombre_entier' && fieldMeta.type !== 'nombre_decimal') return value;
|
||||
if (fieldMeta.min_value !== null && fieldMeta.min_value !== undefined && value < fieldMeta.min_value) {
|
||||
value = fieldMeta.min_value;
|
||||
}
|
||||
if (fieldMeta.max_value !== null && fieldMeta.max_value !== undefined && value > fieldMeta.max_value) {
|
||||
value = fieldMeta.max_value;
|
||||
}
|
||||
return fieldMeta.type === "nombre_decimal" ? value : Math.trunc(value);
|
||||
return fieldMeta.type === 'nombre_decimal' ? value : Math.trunc(value);
|
||||
}
|
||||
|
||||
function forgeApplyDataActionOffline(gameData, action) {
|
||||
var definitionId = String(action.target_definition_id || "");
|
||||
var rowId = action.target_row_id;
|
||||
var fieldName = action.target_field;
|
||||
var operation = action.data_operation;
|
||||
var rawValue = action.data_value;
|
||||
const definitionId = String(action.target_definition_id || '');
|
||||
const rowId = action.target_row_id;
|
||||
const fieldName = action.target_field;
|
||||
const operation = action.data_operation;
|
||||
const rawValue = action.data_value;
|
||||
if (!(definitionId && rowId && fieldName && operation)) return false;
|
||||
var fieldMeta = forgeFindFieldMeta(gameData, definitionId, fieldName);
|
||||
const fieldMeta = forgeFindFieldMeta(gameData, definitionId, fieldName);
|
||||
if (!fieldMeta) return false;
|
||||
var rows = gameData.data && gameData.data[definitionId];
|
||||
const rows = gameData.data && gameData.data[definitionId];
|
||||
if (!rows) return false;
|
||||
var row = null;
|
||||
for (var i = 0; i < rows.length; i++) {
|
||||
let row = null;
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
if (rows[i].id === rowId) { row = rows[i]; break; }
|
||||
}
|
||||
if (!row) return false;
|
||||
var current = row[fieldName];
|
||||
var newValue;
|
||||
const current = row[fieldName];
|
||||
let newValue;
|
||||
try {
|
||||
newValue = forgeComputeNewValue(operation, current, rawValue, fieldMeta.type === "nombre_decimal");
|
||||
newValue = forgeComputeNewValue(operation, current, rawValue, fieldMeta.type === 'nombre_decimal');
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
@@ -140,17 +140,17 @@ function forgeApplyDataActionOffline(gameData, action) {
|
||||
// tout le reste -> null). L'id est un simple auto-incrément local (pas
|
||||
// de vraie base : aucune ligne d'un autre joueur à ignorer hors ligne).
|
||||
function forgeApplyAddRowActionOffline(gameData, action) {
|
||||
var definitionId = String(action.target_definition_id || "");
|
||||
const definitionId = String(action.target_definition_id || '');
|
||||
if (!definitionId) return null;
|
||||
var fields = gameData.fields_meta && gameData.fields_meta[definitionId];
|
||||
const fields = gameData.fields_meta && gameData.fields_meta[definitionId];
|
||||
if (!fields) return null;
|
||||
if (!gameData.data[definitionId]) gameData.data[definitionId] = [];
|
||||
var rows = gameData.data[definitionId];
|
||||
var maxId = 0;
|
||||
rows.forEach(function (r) { if (r.id > maxId) maxId = r.id; });
|
||||
var newRow = { id: maxId + 1 };
|
||||
fields.forEach(function (f) {
|
||||
newRow[f.name] = f.type === "booleen" ? 0 : null;
|
||||
const rows = gameData.data[definitionId];
|
||||
let maxId = 0;
|
||||
rows.forEach((r) => { if (r.id > maxId) maxId = r.id; });
|
||||
const newRow = { id: maxId + 1 };
|
||||
fields.forEach((f) => {
|
||||
newRow[f.name] = f.type === 'booleen' ? 0 : null;
|
||||
});
|
||||
rows.push(newRow);
|
||||
return newRow.id;
|
||||
@@ -158,9 +158,14 @@ function forgeApplyAddRowActionOffline(gameData, action) {
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = {
|
||||
forgeApplyVariableActionOffline, forgeApplyScoreActionOffline, forgeApplyStatusActionOffline,
|
||||
forgeApplyDataActionOffline, forgeApplyAddRowActionOffline,
|
||||
forgeFindFieldMeta, forgeClampToFieldBounds, FORGE_SCORE_STATUS_KEYS,
|
||||
forgeApplyVariableActionOffline,
|
||||
forgeApplyScoreActionOffline,
|
||||
forgeApplyStatusActionOffline,
|
||||
forgeApplyDataActionOffline,
|
||||
forgeApplyAddRowActionOffline,
|
||||
forgeFindFieldMeta,
|
||||
forgeClampToFieldBounds,
|
||||
FORGE_SCORE_STATUS_KEYS,
|
||||
};
|
||||
// node:test charge ce fichier isolément (voir __tests__/) — forgeComputeNewValue
|
||||
// vit dans compute-operation.js, jamais un <script> séparé en test.
|
||||
|
||||
@@ -19,50 +19,52 @@ function forgePyMod(a, b) {
|
||||
}
|
||||
|
||||
function forgeComputeNewValue(operation, current, rawValue, isDecimal) {
|
||||
var round = function (v) { return isDecimal ? v : forgePyInt(v); };
|
||||
if (operation === "definir_texte") return rawValue || "";
|
||||
if (operation === "definir_nombre") {
|
||||
var n = parseFloat(rawValue);
|
||||
return isNaN(n) ? 0 : round(n);
|
||||
const round = function (v) { return isDecimal ? v : forgePyInt(v); };
|
||||
if (operation === 'definir_texte') return rawValue || '';
|
||||
if (operation === 'definir_nombre') {
|
||||
const n = parseFloat(rawValue);
|
||||
return Number.isNaN(n) ? 0 : round(n);
|
||||
}
|
||||
if (operation === "definir_bool_vrai") return 1;
|
||||
if (operation === "definir_bool_faux") return 0;
|
||||
if (operation === "basculer_bool") return current ? 0 : 1;
|
||||
if (operation === "incrementer" || operation === "decrementer") {
|
||||
var amount = (rawValue === null || rawValue === undefined || rawValue === "") ? 0 : parseFloat(rawValue);
|
||||
if (isNaN(amount)) amount = 0;
|
||||
if (operation === "decrementer") amount = -amount;
|
||||
if (operation === 'definir_bool_vrai') return 1;
|
||||
if (operation === 'definir_bool_faux') return 0;
|
||||
if (operation === 'basculer_bool') return current ? 0 : 1;
|
||||
if (operation === 'incrementer' || operation === 'decrementer') {
|
||||
let amount = (rawValue === null || rawValue === undefined || rawValue === '') ? 0 : parseFloat(rawValue);
|
||||
if (Number.isNaN(amount)) amount = 0;
|
||||
if (operation === 'decrementer') amount = -amount;
|
||||
return round((current || 0) + amount);
|
||||
}
|
||||
if (operation === "multiplier") {
|
||||
var factor = (rawValue === null || rawValue === undefined || rawValue === "") ? 1 : parseFloat(rawValue);
|
||||
if (isNaN(factor)) factor = 1;
|
||||
if (operation === 'multiplier') {
|
||||
let factor = (rawValue === null || rawValue === undefined || rawValue === '') ? 1 : parseFloat(rawValue);
|
||||
if (Number.isNaN(factor)) factor = 1;
|
||||
return round((current || 0) * factor);
|
||||
}
|
||||
if (operation === "diviser" || operation === "modulo") {
|
||||
var divisor = (rawValue === null || rawValue === undefined || rawValue === "") ? 0 : parseFloat(rawValue);
|
||||
if (isNaN(divisor)) divisor = 0;
|
||||
if (operation === 'diviser' || operation === 'modulo') {
|
||||
let divisor = (rawValue === null || rawValue === undefined || rawValue === '') ? 0 : parseFloat(rawValue);
|
||||
if (Number.isNaN(divisor)) divisor = 0;
|
||||
if (divisor === 0) return current;
|
||||
var result = operation === "diviser" ? (current || 0) / divisor : forgePyMod(current || 0, divisor);
|
||||
const result = operation === 'diviser' ? (current || 0) / divisor : forgePyMod(current || 0, divisor);
|
||||
return round(result);
|
||||
}
|
||||
if (operation === "minimum" || operation === "maximum") {
|
||||
var bound = parseFloat(rawValue);
|
||||
if (isNaN(bound)) return current;
|
||||
var res = operation === "minimum" ? Math.max(current || 0, bound) : Math.min(current || 0, bound);
|
||||
if (operation === 'minimum' || operation === 'maximum') {
|
||||
const bound = parseFloat(rawValue);
|
||||
if (Number.isNaN(bound)) return current;
|
||||
const res = operation === 'minimum' ? Math.max(current || 0, bound) : Math.min(current || 0, bound);
|
||||
return round(res);
|
||||
}
|
||||
if (operation === "alea") {
|
||||
var parts = String(rawValue || "").split(",");
|
||||
var lo = parseFloat(parts[0]), hi = parseFloat(parts[1]);
|
||||
if (isNaN(lo)) lo = 0;
|
||||
if (isNaN(hi)) hi = 0;
|
||||
if (lo > hi) { var tmp = lo; lo = hi; hi = tmp; }
|
||||
if (isDecimal) return Math.random() * (hi - lo) + lo;
|
||||
var iLo = Math.trunc(lo), iHi = Math.trunc(hi);
|
||||
return Math.floor(Math.random() * (iHi - iLo + 1)) + iLo;
|
||||
if (operation === 'alea') {
|
||||
const parts = String(rawValue || '').split(',');
|
||||
let lo = parseFloat(parts[0]); let
|
||||
hi = parseFloat(parts[1]);
|
||||
if (Number.isNaN(lo)) lo = 0;
|
||||
if (Number.isNaN(hi)) hi = 0;
|
||||
if (lo > hi) { const tmp = lo; lo = hi; hi = tmp; }
|
||||
if (isDecimal) return Math.random() * (hi - lo) + lo; // NOSONAR S2245 - tirage de jeu, jamais crypto
|
||||
const iLo = Math.trunc(lo); const
|
||||
iHi = Math.trunc(hi);
|
||||
return Math.floor(Math.random() * (iHi - iLo + 1)) + iLo; // NOSONAR S2245 - tirage de jeu, jamais crypto
|
||||
}
|
||||
throw new Error("opération inconnue : " + operation);
|
||||
throw new Error(`opération inconnue : ${operation}`);
|
||||
}
|
||||
|
||||
// static/js/play/__tests__/ (node:test) — même patron que conditions.js.
|
||||
|
||||
@@ -7,36 +7,36 @@
|
||||
// _compare() côté Python (chargé avant ce fichier dans templates/
|
||||
// play.html), pas de troisième copie de cette logique.
|
||||
|
||||
var FORGE_REF_PATTERN = /^\{\{\s*([^.{}]+)\.([^.{}]+)\s*\}\}$/;
|
||||
var FORGE_VAR_REF_PATTERN = /^\{\{\s*\$([^.{}[\]]+)((?:\.[^.{}[\]]+|\[\d+\])*)\s*\}\}$/;
|
||||
var FORGE_PATH_SEGMENT_RE = /\.([^.[\]]+)|\[(\d+)\]/g;
|
||||
const FORGE_REF_PATTERN = /^\{\{\s*([^.{}]+)\.([^.{}]+)\s*\}\}$/;
|
||||
const FORGE_VAR_REF_PATTERN = /^\{\{\s*\$([^.{}[\]]+)((?:\.[^.{}[\]]+|\[\d+\])*)\s*\}\}$/;
|
||||
const FORGE_PATH_SEGMENT_RE = /\.([^.[\]]+)|\[(\d+)\]/g;
|
||||
|
||||
// Port de decode_clauses (screens/clause_list_codec.py) — repli sur
|
||||
// l'ancien format à 2 filtres fixes si l'attribut JSON est absent, même
|
||||
// convention que côté serveur (aucune migration de données nécessaire).
|
||||
function forgeDecodeClauses(attrs, clausesAttr, combinatorAttr, legacyPrefix) {
|
||||
var raw = attrs[clausesAttr];
|
||||
var clauses = null;
|
||||
const raw = attrs[clausesAttr];
|
||||
let clauses = null;
|
||||
if (raw) {
|
||||
try {
|
||||
var parsed = JSON.parse(raw);
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) clauses = parsed;
|
||||
} catch (e) { clauses = null; }
|
||||
}
|
||||
if (clauses === null && legacyPrefix) {
|
||||
clauses = [];
|
||||
["", "2"].forEach(function (suffix) {
|
||||
var champ = (attrs[legacyPrefix + suffix + "_champ"] || "").trim();
|
||||
['', '2'].forEach((suffix) => {
|
||||
const champ = (attrs[`${legacyPrefix + suffix}_champ`] || '').trim();
|
||||
if (champ) {
|
||||
clauses.push({
|
||||
champ: champ,
|
||||
operateur: attrs[legacyPrefix + suffix + "_operateur"] || "egal",
|
||||
valeur: attrs[legacyPrefix + suffix + "_valeur"] || "",
|
||||
champ,
|
||||
operateur: attrs[`${legacyPrefix + suffix}_operateur`] || 'egal',
|
||||
valeur: attrs[`${legacyPrefix + suffix}_valeur`] || '',
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
return { clauses: clauses || [], combinator: attrs[combinatorAttr] || "et" };
|
||||
return { clauses: clauses || [], combinator: attrs[combinatorAttr] || 'et' };
|
||||
}
|
||||
|
||||
// Port de _resolve_variable_path — navigue ".champ"/"[index]" dans une
|
||||
@@ -45,16 +45,17 @@ function forgeDecodeClauses(attrs, clausesAttr, combinatorAttr, legacyPrefix) {
|
||||
// planter).
|
||||
function forgeResolveVariablePath(rawValue, path) {
|
||||
if (!path) return rawValue;
|
||||
var current;
|
||||
let current;
|
||||
try {
|
||||
current = rawValue ? JSON.parse(rawValue) : null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
var m;
|
||||
let m;
|
||||
FORGE_PATH_SEGMENT_RE.lastIndex = 0;
|
||||
while ((m = FORGE_PATH_SEGMENT_RE.exec(path)) !== null) {
|
||||
var key = m[1], index = m[2];
|
||||
const key = m[1]; const
|
||||
index = m[2];
|
||||
try {
|
||||
current = key !== undefined ? current[key] : current[parseInt(index, 10)];
|
||||
if (current === undefined) return null;
|
||||
@@ -86,22 +87,23 @@ function forgeResolveVariablePath(rawValue, path) {
|
||||
// définition visée par son nom affiché.
|
||||
function forgeResolveFilterValue(gameData, rawValue) {
|
||||
if (!rawValue) return rawValue;
|
||||
var stripped = String(rawValue).trim();
|
||||
const stripped = String(rawValue).trim();
|
||||
|
||||
var varMatch = FORGE_VAR_REF_PATTERN.exec(stripped);
|
||||
const varMatch = FORGE_VAR_REF_PATTERN.exec(stripped);
|
||||
if (varMatch) {
|
||||
var variable = gameData.variables && gameData.variables[varMatch[1].trim()];
|
||||
const variable = gameData.variables && gameData.variables[varMatch[1].trim()];
|
||||
if (!variable) return null; // variable supprimée depuis : ne matche rien plutôt que planter
|
||||
return forgeResolveVariablePath(variable.value, varMatch[2]);
|
||||
}
|
||||
|
||||
var m = FORGE_REF_PATTERN.exec(stripped);
|
||||
const m = FORGE_REF_PATTERN.exec(stripped);
|
||||
if (!m) return rawValue;
|
||||
var objectName = m[1].trim(), fieldName = m[2].trim();
|
||||
var names = gameData.definition_names || {};
|
||||
for (var defId in names) {
|
||||
const objectName = m[1].trim(); const
|
||||
fieldName = m[2].trim();
|
||||
const names = gameData.definition_names || {};
|
||||
for (const defId in names) {
|
||||
if (names[defId] === objectName) {
|
||||
var rows = gameData.data && gameData.data[defId];
|
||||
const rows = gameData.data && gameData.data[defId];
|
||||
if (rows && rows.length) {
|
||||
return rows[0][fieldName];
|
||||
}
|
||||
@@ -113,41 +115,41 @@ function forgeResolveFilterValue(gameData, rawValue) {
|
||||
|
||||
// Port de _clause_matches.
|
||||
function forgeClauseMatches(gameData, definitionId, row, clause) {
|
||||
var fieldName = (clause.champ || "").trim();
|
||||
const fieldName = (clause.champ || '').trim();
|
||||
if (!fieldName) return true;
|
||||
var operator = clause.operateur || "egal";
|
||||
var expected = forgeResolveFilterValue(gameData, clause.valeur || "");
|
||||
var fieldsMetaForDef = gameData.fields_meta && gameData.fields_meta[definitionId];
|
||||
var field = (fieldsMetaForDef || []).find(function (f) { return f.name === fieldName; });
|
||||
var fieldType = field ? field.type : "texte";
|
||||
const operator = clause.operateur || 'egal';
|
||||
const expected = forgeResolveFilterValue(gameData, clause.valeur || '');
|
||||
const fieldsMetaForDef = gameData.fields_meta && gameData.fields_meta[definitionId];
|
||||
const field = (fieldsMetaForDef || []).find((f) => f.name === fieldName);
|
||||
const fieldType = field ? field.type : 'texte';
|
||||
return compareValues(row[fieldName], operator, expected, fieldType);
|
||||
}
|
||||
|
||||
// Port de filter_rows_by_clauses.
|
||||
function forgeFilterRowsByClauses(gameData, definitionId, rows, clauses, combinator) {
|
||||
if (!clauses || !clauses.length) return rows;
|
||||
if (combinator === "ou") {
|
||||
return rows.filter(function (r) {
|
||||
return clauses.some(function (c) { return forgeClauseMatches(gameData, definitionId, r, c); });
|
||||
});
|
||||
if (combinator === 'ou') {
|
||||
return rows.filter((r) => clauses.some((c) => forgeClauseMatches(gameData, definitionId, r, c)));
|
||||
}
|
||||
return rows.filter(function (r) {
|
||||
return clauses.every(function (c) { return forgeClauseMatches(gameData, definitionId, r, c); });
|
||||
});
|
||||
return rows.filter((r) => clauses.every((c) => forgeClauseMatches(gameData, definitionId, r, c)));
|
||||
}
|
||||
|
||||
// Port de filter_repeater_rows — filtre appliqué par le widget Répéteur
|
||||
// lui-même (attrs._filtres_json/_filtre_combinateur, avec repli sur
|
||||
// l'ancien format à 2 filtres fixes _filtre(2)_champ/...).
|
||||
function forgeFilterRepeaterRows(gameData, definitionId, rows, attrs) {
|
||||
var decoded = forgeDecodeClauses(attrs, "_filtres_json", "_filtre_combinateur", "_filtre");
|
||||
const decoded = forgeDecodeClauses(attrs, '_filtres_json', '_filtre_combinateur', '_filtre');
|
||||
return forgeFilterRowsByClauses(gameData, definitionId, rows, decoded.clauses, decoded.combinator);
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = {
|
||||
forgeDecodeClauses, forgeResolveVariablePath,
|
||||
forgeResolveFilterValue, forgeClauseMatches, forgeFilterRowsByClauses, forgeFilterRepeaterRows,
|
||||
forgeDecodeClauses,
|
||||
forgeResolveVariablePath,
|
||||
forgeResolveFilterValue,
|
||||
forgeClauseMatches,
|
||||
forgeFilterRowsByClauses,
|
||||
forgeFilterRepeaterRows,
|
||||
};
|
||||
global.compareValues = global.compareValues || require('../conditions.js').compareValues;
|
||||
}
|
||||
|
||||
@@ -15,23 +15,23 @@
|
||||
// static/js/play/actions.js, appelée par l'action "Modifier un objet de
|
||||
// scène" — comportement identique en ligne et hors ligne, rien à porter).
|
||||
function forgeRecomputeElementsForScreenOffline(gameData, screen) {
|
||||
var elements = screen.elements || [];
|
||||
var childrenMap = {};
|
||||
elements.forEach(function (el) {
|
||||
const elements = screen.elements || [];
|
||||
const childrenMap = {};
|
||||
elements.forEach((el) => {
|
||||
if (el.parent_id) {
|
||||
if (!childrenMap[el.parent_id]) childrenMap[el.parent_id] = [];
|
||||
childrenMap[el.parent_id].push(el);
|
||||
}
|
||||
});
|
||||
var playCtx = { _forge_play_mode: true };
|
||||
elements.forEach(function (el) {
|
||||
const playCtx = { _forge_play_mode: true };
|
||||
elements.forEach((el) => {
|
||||
el.rendered_html = forgeRenderElementHtml(el, gameData, childrenMap, playCtx);
|
||||
});
|
||||
}
|
||||
|
||||
function forgeRecomputeFullPayloadOffline(gameData) {
|
||||
(gameData.screens || []).forEach(function (s) {
|
||||
if (s.kind !== "jeu_2d") forgeRecomputeElementsForScreenOffline(gameData, s);
|
||||
(gameData.screens || []).forEach((s) => {
|
||||
if (s.kind !== 'jeu_2d') forgeRecomputeElementsForScreenOffline(gameData, s);
|
||||
});
|
||||
return gameData;
|
||||
}
|
||||
|
||||
@@ -15,68 +15,83 @@
|
||||
// play.html) et sont routés depuis forgeRenderElementHtml() ci-dessous
|
||||
// via forgeSpecialRenderer().
|
||||
|
||||
var FORGE_LAYOUT_CAPABLE_WIDGETS = ["conteneur", "repeteur_donnees", "liste", "liste_numerotee", "groupe_champs", "tableau"];
|
||||
const FORGE_LAYOUT_CAPABLE_WIDGETS = ['conteneur', 'repeteur_donnees', 'liste', 'liste_numerotee', 'groupe_champs', 'tableau'];
|
||||
|
||||
// Widgets à balise simple portés à l'étape 3 — {tag, hasContent,
|
||||
// selfClosing, fixedAttrs} — port direct de screens/widgets/registry.py
|
||||
// (seuls les champs utiles au RENDU, pas au panneau de propriétés de
|
||||
// l'éditeur, qui ne concerne que le port serveur).
|
||||
var FORGE_SIMPLE_WIDGETS = {
|
||||
texte: { tag: "p", hasContent: true },
|
||||
titre: { tag: null, hasContent: true, fixedAttrs: { class: "title" } },
|
||||
bouton: { tag: "button", hasContent: true, fixedAttrs: { class: "button" } },
|
||||
lien: { tag: "a", hasContent: true },
|
||||
image: { tag: "img", hasContent: false, selfClosing: true },
|
||||
video: { tag: "video", hasContent: false },
|
||||
champ_texte: { tag: "input", hasContent: false, selfClosing: true, fixedAttrs: { type: "text", class: "input" } },
|
||||
champ_email: { tag: "input", hasContent: false, selfClosing: true, fixedAttrs: { type: "email", class: "input" } },
|
||||
champ_mot_de_passe: { tag: "input", hasContent: false, selfClosing: true, fixedAttrs: { type: "password", class: "input" } },
|
||||
zone_texte: { tag: "textarea", hasContent: false, fixedAttrs: { class: "textarea" } },
|
||||
liste: { tag: "ul", hasContent: true },
|
||||
liste_numerotee: { tag: "ol", hasContent: true },
|
||||
separateur: { tag: "hr", hasContent: false, selfClosing: true },
|
||||
conteneur: { tag: "div", hasContent: true, fixedAttrs: { class: "box" } },
|
||||
const FORGE_SIMPLE_WIDGETS = {
|
||||
texte: { tag: 'p', hasContent: true },
|
||||
titre: { tag: null, hasContent: true, fixedAttrs: { class: 'title' } },
|
||||
bouton: { tag: 'button', hasContent: true, fixedAttrs: { class: 'button' } },
|
||||
lien: { tag: 'a', hasContent: true },
|
||||
image: { tag: 'img', hasContent: false, selfClosing: true },
|
||||
video: { tag: 'video', hasContent: false },
|
||||
champ_texte: {
|
||||
tag: 'input', hasContent: false, selfClosing: true, fixedAttrs: { type: 'text', class: 'input' },
|
||||
},
|
||||
champ_email: {
|
||||
tag: 'input', hasContent: false, selfClosing: true, fixedAttrs: { type: 'email', class: 'input' },
|
||||
},
|
||||
champ_mot_de_passe: {
|
||||
tag: 'input', hasContent: false, selfClosing: true, fixedAttrs: { type: 'password', class: 'input' },
|
||||
},
|
||||
zone_texte: { tag: 'textarea', hasContent: false, fixedAttrs: { class: 'textarea' } },
|
||||
liste: { tag: 'ul', hasContent: true },
|
||||
liste_numerotee: { tag: 'ol', hasContent: true },
|
||||
separateur: { tag: 'hr', hasContent: false, selfClosing: true },
|
||||
conteneur: { tag: 'div', hasContent: true, fixedAttrs: { class: 'box' } },
|
||||
};
|
||||
|
||||
// widget -> clé de special_render (voir screens/widgets/registry.py).
|
||||
var FORGE_SPECIAL_RENDER_BY_WIDGET = {
|
||||
case_a_cocher: "checkbox_label", bouton_radio: "checkbox_label", liste_deroulante: "select_options",
|
||||
groupe_champs: "fieldset_legend", tableau: "table", repeteur_donnees: "repeater",
|
||||
jauge: "jauge", superposition: "overlay", onglets: "onglets", icone: "icone", personnage: "personnage",
|
||||
const FORGE_SPECIAL_RENDER_BY_WIDGET = {
|
||||
case_a_cocher: 'checkbox_label',
|
||||
bouton_radio: 'checkbox_label',
|
||||
liste_deroulante: 'select_options',
|
||||
groupe_champs: 'fieldset_legend',
|
||||
tableau: 'table',
|
||||
repeteur_donnees: 'repeater',
|
||||
jauge: 'jauge',
|
||||
superposition: 'overlay',
|
||||
onglets: 'onglets',
|
||||
icone: 'icone',
|
||||
personnage: 'personnage',
|
||||
};
|
||||
|
||||
// fixed_attrs des widgets à special_render (voir screens/widgets/registry.py)
|
||||
// — seul "tableau" en a un, tous les autres n'en ont aucun (contrairement
|
||||
// à "conteneur", qui ne doit JAMAIS servir de repli pour eux).
|
||||
var FORGE_SPECIAL_WIDGET_FIXED_ATTRS = { tableau: { class: "table" } };
|
||||
const FORGE_SPECIAL_WIDGET_FIXED_ATTRS = { tableau: { class: 'table' } };
|
||||
|
||||
function forgeHtmlEscape(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
||||
.replace(/"/g, """).replace(/'/g, "'");
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// Port de apply_ctx.py — {{champ}} remplacé par la valeur de la ligne en
|
||||
// cours (contenu ET attributs), à l'intérieur d'un Répéteur.
|
||||
function forgeApplyCtx(text, ctx) {
|
||||
if (!ctx || !text) return text;
|
||||
var result = text;
|
||||
Object.keys(ctx).forEach(function (k) {
|
||||
if (k === "_forge_play_mode") return; // indicateur interne, pas un {{champ}}
|
||||
result = result.split("{{" + k + "}}").join(ctx[k]);
|
||||
let result = text;
|
||||
Object.keys(ctx).forEach((k) => {
|
||||
if (k === '_forge_play_mode') return; // indicateur interne, pas un {{champ}}
|
||||
result = result.split(`{{${k}}}`).join(ctx[k]);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function forgeAutoId(el) {
|
||||
return "elt-" + el.id;
|
||||
return `elt-${el.id}`;
|
||||
}
|
||||
|
||||
// Port de parent_flex_direction.py.
|
||||
function forgeParentFlexDirection(parentEl) {
|
||||
var style = (parentEl && parentEl.style) || {};
|
||||
if (style.display !== "flex") return null;
|
||||
return style["flex-direction"] || "row";
|
||||
const style = (parentEl && parentEl.style) || {};
|
||||
if (style.display !== 'flex') return null;
|
||||
return style['flex-direction'] || 'row';
|
||||
}
|
||||
|
||||
// Port de style_string.py — même logique de taille par défaut (100% hors
|
||||
@@ -84,34 +99,34 @@ function forgeParentFlexDirection(parentEl) {
|
||||
// enfant), même garde-fous (transform réservé au cadre extérieur pour un
|
||||
// élément posé directement sur l'écran, 0px ignoré = pas de surcharge).
|
||||
function forgeStyleString(el, parentFlexDirection, widgetKey) {
|
||||
var style = Object.assign({}, el.style || {});
|
||||
if (FORGE_LAYOUT_CAPABLE_WIDGETS.indexOf(widgetKey) !== -1 && !("display" in style)) {
|
||||
style.display = "flex";
|
||||
if (!("flex-direction" in style)) style["flex-direction"] = "column";
|
||||
const style = { ...el.style || {} };
|
||||
if (FORGE_LAYOUT_CAPABLE_WIDGETS.indexOf(widgetKey) !== -1 && !('display' in style)) {
|
||||
style.display = 'flex';
|
||||
if (!('flex-direction' in style)) style['flex-direction'] = 'column';
|
||||
}
|
||||
var isChild = !!el.parent_id;
|
||||
var baseStyle;
|
||||
const isChild = !!el.parent_id;
|
||||
let baseStyle;
|
||||
if (isChild) {
|
||||
var widthDefault = (parentFlexDirection === "row" || parentFlexDirection === "row-reverse") ? "auto" : "100%";
|
||||
baseStyle = "width:" + widthDefault + "; height:auto; box-sizing:border-box; margin:0;";
|
||||
const widthDefault = (parentFlexDirection === 'row' || parentFlexDirection === 'row-reverse') ? 'auto' : '100%';
|
||||
baseStyle = `width:${widthDefault}; height:auto; box-sizing:border-box; margin:0;`;
|
||||
} else {
|
||||
baseStyle = "width:100%; height:100%; box-sizing:border-box; margin:0;";
|
||||
baseStyle = 'width:100%; height:100%; box-sizing:border-box; margin:0;';
|
||||
}
|
||||
var hasSizeOverride = false;
|
||||
var parts = [];
|
||||
Object.keys(style).forEach(function (k) {
|
||||
var v = style[k];
|
||||
if (v === null || v === undefined || v === "") return;
|
||||
if (typeof v === "object") return; // filet de sécurité, voir style_string.py
|
||||
if (k === "transform" && !isChild) return;
|
||||
var strV = String(v).trim();
|
||||
if ((k === "width" || k === "height") && (strV === "0px" || strV === "0")) return;
|
||||
if (k === "width" || k === "height") hasSizeOverride = true;
|
||||
parts.push(k + ":" + v);
|
||||
let hasSizeOverride = false;
|
||||
const parts = [];
|
||||
Object.keys(style).forEach((k) => {
|
||||
const v = style[k];
|
||||
if (v === null || v === undefined || v === '') return;
|
||||
if (typeof v === 'object') return; // filet de sécurité, voir style_string.py
|
||||
if (k === 'transform' && !isChild) return;
|
||||
const strV = String(v).trim();
|
||||
if ((k === 'width' || k === 'height') && (strV === '0px' || strV === '0')) return;
|
||||
if (k === 'width' || k === 'height') hasSizeOverride = true;
|
||||
parts.push(`${k}:${v}`);
|
||||
});
|
||||
if (hasSizeOverride) parts.push("flex-shrink:0");
|
||||
var userStyle = parts.join("; ");
|
||||
return baseStyle + (userStyle ? " " + userStyle + ";" : "");
|
||||
if (hasSizeOverride) parts.push('flex-shrink:0');
|
||||
const userStyle = parts.join('; ');
|
||||
return baseStyle + (userStyle ? ` ${userStyle};` : '');
|
||||
}
|
||||
|
||||
// Port de attr_string.py. "alt" est le seul attribut dont la chaîne VIDE
|
||||
@@ -120,69 +135,67 @@ function forgeStyleString(el, parentFlexDirection, widgetKey) {
|
||||
// lecteur d'écran) — tous les autres attributs vides sont omis comme
|
||||
// avant (une valeur vide y équivaut à "non réglé").
|
||||
function forgeAttrString(attrs, extraStyle) {
|
||||
var parts = ['style="' + forgeHtmlEscape(extraStyle) + '"'];
|
||||
Object.keys(attrs).forEach(function (k) {
|
||||
var v = attrs[k];
|
||||
const parts = [`style="${forgeHtmlEscape(extraStyle)}"`];
|
||||
Object.keys(attrs).forEach((k) => {
|
||||
const v = attrs[k];
|
||||
if (v === null || v === undefined || v === false) return;
|
||||
if (v === "" && k !== "alt") return;
|
||||
parts.push(v === true ? forgeHtmlEscape(k) : forgeHtmlEscape(k) + '="' + forgeHtmlEscape(v) + '"');
|
||||
if (v === '' && k !== 'alt') return;
|
||||
parts.push(v === true ? forgeHtmlEscape(k) : `${forgeHtmlEscape(k)}="${forgeHtmlEscape(v)}"`);
|
||||
});
|
||||
return parts.join(" ");
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
// Port de visible_attrs.py.
|
||||
function forgeVisibleAttrs(el, meta, ctx) {
|
||||
var rawAttrs = el.attributes || {};
|
||||
var attrs = {};
|
||||
Object.keys(rawAttrs).forEach(function (k) {
|
||||
if (k.indexOf("_") === 0) return;
|
||||
var v = rawAttrs[k];
|
||||
attrs[k] = typeof v === "string" ? forgeApplyCtx(v, ctx) : v;
|
||||
const rawAttrs = el.attributes || {};
|
||||
const attrs = {};
|
||||
Object.keys(rawAttrs).forEach((k) => {
|
||||
if (k.indexOf('_') === 0) return;
|
||||
const v = rawAttrs[k];
|
||||
attrs[k] = typeof v === 'string' ? forgeApplyCtx(v, ctx) : v;
|
||||
});
|
||||
Object.assign(attrs, meta.fixedAttrs || {});
|
||||
var extraClasses = Object.keys(rawAttrs)
|
||||
.filter(function (k) { return k.indexOf("_class_") === 0 && rawAttrs[k]; })
|
||||
.map(function (k) { return rawAttrs[k]; });
|
||||
const extraClasses = Object.keys(rawAttrs)
|
||||
.filter((k) => k.indexOf('_class_') === 0 && rawAttrs[k])
|
||||
.map((k) => rawAttrs[k]);
|
||||
if (extraClasses.length) {
|
||||
var baseClass = attrs.class || "";
|
||||
attrs.class = (baseClass ? [baseClass] : []).concat(extraClasses).join(" ");
|
||||
const baseClass = attrs.class || '';
|
||||
attrs.class = (baseClass ? [baseClass] : []).concat(extraClasses).join(' ');
|
||||
}
|
||||
if (!("id" in attrs)) attrs.id = forgeAutoId(el);
|
||||
attrs["data-element-id"] = el.id;
|
||||
if (el.parent_id) attrs["data-anim-target"] = el.id;
|
||||
if (!('id' in attrs)) attrs.id = forgeAutoId(el);
|
||||
attrs['data-element-id'] = el.id;
|
||||
if (el.parent_id) attrs['data-anim-target'] = el.id;
|
||||
return attrs;
|
||||
}
|
||||
|
||||
// Port de render_children.py.
|
||||
function forgeRenderChildren(el, gameData, childrenMap, ctx) {
|
||||
var kids = (childrenMap && childrenMap[el.id]) || [];
|
||||
var flexDir = forgeParentFlexDirection(el);
|
||||
return kids.map(function (c) {
|
||||
return forgeRenderElementHtml(c, gameData, childrenMap, ctx, flexDir);
|
||||
}).join("");
|
||||
const kids = (childrenMap && childrenMap[el.id]) || [];
|
||||
const flexDir = forgeParentFlexDirection(el);
|
||||
return kids.map((c) => forgeRenderElementHtml(c, gameData, childrenMap, ctx, flexDir)).join('');
|
||||
}
|
||||
|
||||
// Port de resolve_bound_row.py (_resolve_bound_row_ctx) — "Donnée liée"
|
||||
// d'un Texte/Titre : retrouve LA ligne qui correspond aux conditions
|
||||
// réglées (moteur partagé avec le Répéteur, voir filter-repeater-rows.js).
|
||||
function forgeResolveBoundRowCtx(gameData, attrs) {
|
||||
var definitionId = attrs._data_definition_id;
|
||||
let definitionId = attrs._data_definition_id;
|
||||
if (!definitionId) return null;
|
||||
definitionId = String(definitionId);
|
||||
var fieldsMetaForDef = gameData.fields_meta && gameData.fields_meta[definitionId];
|
||||
const fieldsMetaForDef = gameData.fields_meta && gameData.fields_meta[definitionId];
|
||||
if (!fieldsMetaForDef) return null;
|
||||
var rows = (gameData.data && gameData.data[definitionId]) || [];
|
||||
var decoded = forgeDecodeClauses(attrs, "_data_filtres_json", "_data_filtre_combinateur", "_data_filtre");
|
||||
let rows = (gameData.data && gameData.data[definitionId]) || [];
|
||||
const decoded = forgeDecodeClauses(attrs, '_data_filtres_json', '_data_filtre_combinateur', '_data_filtre');
|
||||
rows = forgeFilterRowsByClauses(gameData, definitionId, rows, decoded.clauses, decoded.combinator);
|
||||
if (!rows.length) return null;
|
||||
var row = rows[0];
|
||||
var ctx = {};
|
||||
fieldsMetaForDef.forEach(function (f) {
|
||||
const row = rows[0];
|
||||
const ctx = {};
|
||||
fieldsMetaForDef.forEach((f) => {
|
||||
// row[f.name] : gameData.data est déjà indexé par le nom D'AFFICHAGE
|
||||
// du champ (voir full_game_payload.py), jamais par sa colonne SQL —
|
||||
// voir filter-repeater-rows.js pour le détail de ce piège.
|
||||
var v = row[f.name];
|
||||
ctx[f.name] = (v === null || v === undefined || v === "") ? "" : String(v);
|
||||
const v = row[f.name];
|
||||
ctx[f.name] = (v === null || v === undefined || v === '') ? '' : String(v);
|
||||
});
|
||||
return ctx;
|
||||
}
|
||||
@@ -193,21 +206,19 @@ function forgeResolveBoundRowCtx(gameData, attrs) {
|
||||
// une fois à l'export, voir full_game_payload.py) plutôt que depuis
|
||||
// d'éventuels enfants copiés en base.
|
||||
function forgeRenderElementTypeChildren(el, gameData, ctx) {
|
||||
var templateTree = gameData.element_type_templates && gameData.element_type_templates[String(el.element_type_id)];
|
||||
const templateTree = gameData.element_type_templates && gameData.element_type_templates[String(el.element_type_id)];
|
||||
if (!templateTree || !templateTree.top.length) return [null, false];
|
||||
var isOverlayOnly = templateTree.top.every(function (c) { return c.widget === "superposition"; });
|
||||
var top = templateTree.top;
|
||||
const isOverlayOnly = templateTree.top.every((c) => c.widget === 'superposition');
|
||||
let { top } = templateTree;
|
||||
if (isOverlayOnly) {
|
||||
var overlayRoot = Object.assign({}, top[0]);
|
||||
var newStyle = {};
|
||||
Object.keys(overlayRoot.style || {}).forEach(function (k) { if (k !== "display") newStyle[k] = overlayRoot.style[k]; });
|
||||
const overlayRoot = { ...top[0] };
|
||||
const newStyle = {};
|
||||
Object.keys(overlayRoot.style || {}).forEach((k) => { if (k !== 'display') newStyle[k] = overlayRoot.style[k]; });
|
||||
overlayRoot.style = newStyle;
|
||||
top = top.slice();
|
||||
top[0] = overlayRoot;
|
||||
}
|
||||
var html = top.map(function (c) {
|
||||
return forgeRenderElementHtml(c, gameData, templateTree.children_map, ctx);
|
||||
}).join("");
|
||||
const html = top.map((c) => forgeRenderElementHtml(c, gameData, templateTree.children_map, ctx)).join('');
|
||||
return [html, isOverlayOnly];
|
||||
}
|
||||
|
||||
@@ -220,16 +231,16 @@ function forgeRenderElementTypeChildren(el, gameData, ctx) {
|
||||
// enfants/onglets/superposition).
|
||||
function forgeSpecialRenderer(specialKey) {
|
||||
switch (specialKey) {
|
||||
case "checkbox_label": return forgeRenderCheckboxOrRadio;
|
||||
case "select_options": return forgeRenderSelect;
|
||||
case "fieldset_legend": return forgeRenderFieldset;
|
||||
case "table": return forgeRenderTable;
|
||||
case "repeater": return forgeRenderRepeater;
|
||||
case "jauge": return forgeRenderJauge;
|
||||
case "overlay": return forgeRenderOverlay;
|
||||
case "onglets": return forgeRenderOnglets;
|
||||
case "icone": return forgeRenderIcone;
|
||||
case "personnage": return forgeRenderPersonnage;
|
||||
case 'checkbox_label': return forgeRenderCheckboxOrRadio;
|
||||
case 'select_options': return forgeRenderSelect;
|
||||
case 'fieldset_legend': return forgeRenderFieldset;
|
||||
case 'table': return forgeRenderTable;
|
||||
case 'repeater': return forgeRenderRepeater;
|
||||
case 'jauge': return forgeRenderJauge;
|
||||
case 'overlay': return forgeRenderOverlay;
|
||||
case 'onglets': return forgeRenderOnglets;
|
||||
case 'icone': return forgeRenderIcone;
|
||||
case 'personnage': return forgeRenderPersonnage;
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
@@ -238,77 +249,78 @@ function forgeSpecialRenderer(specialKey) {
|
||||
// pas hors ligne (une seule partie en mémoire, pas de base de données) :
|
||||
// tout ce qui en dépendait côté serveur lit désormais `gameData`.
|
||||
function forgeRenderElementHtml(el, gameData, childrenMap, ctx, parentFlexDirection) {
|
||||
var widgetKey = el.widget;
|
||||
var simple = FORGE_SIMPLE_WIDGETS[widgetKey];
|
||||
var specialKey = FORGE_SPECIAL_RENDER_BY_WIDGET[widgetKey];
|
||||
var attrsRaw = el.attributes || {};
|
||||
const widgetKey = el.widget;
|
||||
const simple = FORGE_SIMPLE_WIDGETS[widgetKey];
|
||||
const specialKey = FORGE_SPECIAL_RENDER_BY_WIDGET[widgetKey];
|
||||
const attrsRaw = el.attributes || {};
|
||||
|
||||
var hasVisibilityCondition = attrsRaw._visibilite_cond_mode === "variable" || attrsRaw._visibilite_cond_mode === "objet";
|
||||
var playMode = !!((ctx || {})._forge_play_mode);
|
||||
const hasVisibilityCondition = attrsRaw._visibilite_cond_mode === 'variable' || attrsRaw._visibilite_cond_mode === 'objet';
|
||||
const playMode = !!((ctx || {})._forge_play_mode);
|
||||
if (playMode && hasVisibilityCondition && !forgeIsElementVisibleOffline(gameData, attrsRaw, ctx)) {
|
||||
return '<div class="visibilityGated" data-element-id="' + el.id + '" style="display:none;"></div>';
|
||||
return `<div class="visibilityGated" data-element-id="${el.id}" style="display:none;"></div>`;
|
||||
}
|
||||
var hasDataBinding = !!attrsRaw._data_definition_id;
|
||||
var mark = function (htmlStr) {
|
||||
var markers = "";
|
||||
if (hasVisibilityCondition) markers += "<!--visibilityGated-->";
|
||||
if (hasDataBinding) markers += "<!--dataBound-->";
|
||||
const hasDataBinding = !!attrsRaw._data_definition_id;
|
||||
const mark = function (htmlStr) {
|
||||
let markers = '';
|
||||
if (hasVisibilityCondition) markers += '<!--visibilityGated-->';
|
||||
if (hasDataBinding) markers += '<!--dataBound-->';
|
||||
return htmlStr + markers;
|
||||
};
|
||||
|
||||
var boundCtx = forgeResolveBoundRowCtx(gameData, attrsRaw);
|
||||
if (boundCtx) ctx = Object.assign({}, ctx || {}, boundCtx);
|
||||
const boundCtx = forgeResolveBoundRowCtx(gameData, attrsRaw);
|
||||
if (boundCtx) ctx = { ...ctx || {}, ...boundCtx };
|
||||
|
||||
// meta d'un widget à special_render : fixedAttrs propre (voir
|
||||
// screens/widgets/registry.py — seul "tableau" en a un, "class":"table"),
|
||||
// jamais celui de "conteneur" ("class":"box" polluerait tout le reste).
|
||||
var meta = simple || { fixedAttrs: FORGE_SPECIAL_WIDGET_FIXED_ATTRS[widgetKey] || {} };
|
||||
const meta = simple || { fixedAttrs: FORGE_SPECIAL_WIDGET_FIXED_ATTRS[widgetKey] || {} };
|
||||
if (specialKey) {
|
||||
var renderer = forgeSpecialRenderer(specialKey);
|
||||
const renderer = forgeSpecialRenderer(specialKey);
|
||||
return mark(renderer(el, meta, gameData, childrenMap, ctx, parentFlexDirection));
|
||||
}
|
||||
|
||||
var tag = meta.tag;
|
||||
let { tag } = meta;
|
||||
if (tag === null) { // widget à balise dynamique (ex: "titre" -> h1/h2/h3)
|
||||
var niveau = attrsRaw._niveau || "2";
|
||||
tag = "h" + niveau;
|
||||
const niveau = attrsRaw._niveau || '2';
|
||||
tag = `h${niveau}`;
|
||||
}
|
||||
|
||||
var attrs = forgeVisibleAttrs(el, meta, ctx);
|
||||
var style = forgeStyleString(el, parentFlexDirection, widgetKey);
|
||||
const attrs = forgeVisibleAttrs(el, meta, ctx);
|
||||
const style = forgeStyleString(el, parentFlexDirection, widgetKey);
|
||||
|
||||
var hoverText = attrsRaw._survol_texte;
|
||||
if (hoverText) attrs["data-hover-text"] = forgeApplyCtx(hoverText, ctx);
|
||||
const hoverText = attrsRaw._survol_texte;
|
||||
if (hoverText) attrs['data-hover-text'] = forgeApplyCtx(hoverText, ctx);
|
||||
|
||||
// alt (RGAA/WCAG 1.1.1) : le widget "image" n'a pas de champ de
|
||||
// description dédié dans l'éditeur — alt="" (décoratif) par défaut
|
||||
// plutôt qu'aucun attribut du tout, jamais le nom de fichier brut par
|
||||
// défaut du navigateur. Ne touche pas à un alt déjà posé (pass-through
|
||||
// via forgeVisibleAttrs, si l'auteur en règle un un jour).
|
||||
if (tag === "img" && !("alt" in attrs)) attrs.alt = "";
|
||||
if (tag === 'img' && !('alt' in attrs)) attrs.alt = '';
|
||||
|
||||
if (meta.selfClosing) {
|
||||
return mark("<" + tag + " " + forgeAttrString(attrs, style) + ">");
|
||||
return mark(`<${tag} ${forgeAttrString(attrs, style)}>`);
|
||||
}
|
||||
|
||||
var content = forgeApplyCtx(el.content || "", ctx);
|
||||
var childResult = el.element_type_id ? forgeRenderElementTypeChildren(el, gameData, ctx) : [null, false];
|
||||
var childHtml = childResult[0], isOverlayTemplate = childResult[1];
|
||||
const content = forgeApplyCtx(el.content || '', ctx);
|
||||
const childResult = el.element_type_id ? forgeRenderElementTypeChildren(el, gameData, ctx) : [null, false];
|
||||
let childHtml = childResult[0]; const
|
||||
isOverlayTemplate = childResult[1];
|
||||
if (childHtml === null) {
|
||||
childHtml = forgeRenderChildren(el, gameData, childrenMap, ctx);
|
||||
} else if (isOverlayTemplate) {
|
||||
delete attrs.class;
|
||||
return mark("<div " + forgeAttrString(attrs, style) + ">" + childHtml + "</div>");
|
||||
return mark(`<div ${forgeAttrString(attrs, style)}>${childHtml}</div>`);
|
||||
}
|
||||
|
||||
if (tag === "ul" || tag === "ol") {
|
||||
var items = content.split("\n").map(function (l) { return l.trim(); }).filter(Boolean);
|
||||
var inner = items.map(function (item) { return "<li>" + forgeHtmlEscape(item) + "</li>"; }).join("") + childHtml;
|
||||
return mark("<" + tag + " " + forgeAttrString(attrs, style) + ">" + inner + "</" + tag + ">");
|
||||
if (tag === 'ul' || tag === 'ol') {
|
||||
const items = content.split('\n').map((l) => l.trim()).filter(Boolean);
|
||||
const inner = items.map((item) => `<li>${forgeHtmlEscape(item)}</li>`).join('') + childHtml;
|
||||
return mark(`<${tag} ${forgeAttrString(attrs, style)}>${inner}</${tag}>`);
|
||||
}
|
||||
|
||||
var safeContent = content ? forgeHtmlEscape(content).replace(/\n/g, "<br>") : "";
|
||||
return mark("<" + tag + " " + forgeAttrString(attrs, style) + ">" + safeContent + childHtml + "</" + tag + ">");
|
||||
const safeContent = content ? forgeHtmlEscape(content).replace(/\n/g, '<br>') : '';
|
||||
return mark(`<${tag} ${forgeAttrString(attrs, style)}>${safeContent}${childHtml}</${tag}>`);
|
||||
}
|
||||
|
||||
// NOTE (node:test uniquement) : forgeRenderElementHtml() référence, pour
|
||||
@@ -324,14 +336,24 @@ function forgeRenderElementHtml(el, gameData, childrenMap, ctx, parentFlexDirect
|
||||
// fonctions ci-dessus pour leurs propres enfants/onglets/superposition).
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = {
|
||||
forgeHtmlEscape, forgeApplyCtx, forgeAutoId, forgeParentFlexDirection, forgeStyleString,
|
||||
forgeAttrString, forgeVisibleAttrs, forgeRenderChildren, forgeRenderElementHtml,
|
||||
forgeResolveBoundRowCtx, forgeRenderElementTypeChildren, forgeSpecialRenderer,
|
||||
FORGE_SIMPLE_WIDGETS, FORGE_SPECIAL_RENDER_BY_WIDGET,
|
||||
forgeHtmlEscape,
|
||||
forgeApplyCtx,
|
||||
forgeAutoId,
|
||||
forgeParentFlexDirection,
|
||||
forgeStyleString,
|
||||
forgeAttrString,
|
||||
forgeVisibleAttrs,
|
||||
forgeRenderChildren,
|
||||
forgeRenderElementHtml,
|
||||
forgeResolveBoundRowCtx,
|
||||
forgeRenderElementTypeChildren,
|
||||
forgeSpecialRenderer,
|
||||
FORGE_SIMPLE_WIDGETS,
|
||||
FORGE_SPECIAL_RENDER_BY_WIDGET,
|
||||
};
|
||||
global.forgeIsElementVisibleOffline = global.forgeIsElementVisibleOffline
|
||||
|| require('./visibility-condition.js').forgeIsElementVisibleOffline;
|
||||
var filterMod = require('./filter-repeater-rows.js');
|
||||
const filterMod = require('./filter-repeater-rows.js');
|
||||
global.forgeResolveFilterValue = global.forgeResolveFilterValue || filterMod.forgeResolveFilterValue;
|
||||
global.forgeResolveVariablePath = global.forgeResolveVariablePath || filterMod.forgeResolveVariablePath;
|
||||
global.forgeDecodeClauses = global.forgeDecodeClauses || filterMod.forgeDecodeClauses;
|
||||
|
||||
@@ -4,16 +4,17 @@
|
||||
// au lieu d'une requête SQL (voir _read_field_value côté serveur).
|
||||
|
||||
function forgeHexToRgb(hexColor) {
|
||||
var hex = (hexColor || "#5b8cff").replace(/^#/, "");
|
||||
if (hex.length !== 6) hex = "5b8cff";
|
||||
let hex = (hexColor || '#5b8cff').replace(/^#/, '');
|
||||
if (hex.length !== 6) hex = '5b8cff';
|
||||
return [parseInt(hex.substr(0, 2), 16), parseInt(hex.substr(2, 2), 16), parseInt(hex.substr(4, 2), 16)];
|
||||
}
|
||||
|
||||
function forgeInterpolateColor(lowHex, highHex, ratio) {
|
||||
var lo = forgeHexToRgb(lowHex), hi = forgeHexToRgb(highHex);
|
||||
var mixed = lo.map(function (c, i) { return Math.round(c + (hi[i] - c) * ratio); });
|
||||
var toHex = function (n) { return n.toString(16).padStart(2, "0"); };
|
||||
return "#" + mixed.map(toHex).join("");
|
||||
const lo = forgeHexToRgb(lowHex); const
|
||||
hi = forgeHexToRgb(highHex);
|
||||
const mixed = lo.map((c, i) => Math.round(c + (hi[i] - c) * ratio));
|
||||
const toHex = function (n) { return n.toString(16).padStart(2, '0'); };
|
||||
return `#${mixed.map(toHex).join('')}`;
|
||||
}
|
||||
|
||||
// Port de _read_field_value — repli sur la ligne la PLUS RÉCENTE si
|
||||
@@ -22,88 +23,84 @@ function forgeInterpolateColor(lowHex, highHex, ratio) {
|
||||
function forgeReadFieldValue(gameData, definitionId, rowId, fieldName) {
|
||||
if (!definitionId || !fieldName) return null;
|
||||
definitionId = String(definitionId);
|
||||
var rows = gameData.data && gameData.data[definitionId];
|
||||
const rows = gameData.data && gameData.data[definitionId];
|
||||
if (!rows || !rows.length) return null;
|
||||
var row = null;
|
||||
if (rowId) row = rows.find(function (r) { return String(r.id) === String(rowId); });
|
||||
let row = null;
|
||||
if (rowId) row = rows.find((r) => String(r.id) === String(rowId));
|
||||
if (!row) row = rows[0];
|
||||
return row[fieldName];
|
||||
}
|
||||
|
||||
function forgeRenderJauge(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
|
||||
var attrs = el.attributes || {};
|
||||
var nomPosition = attrs._nom_position || "dessus";
|
||||
var nomAlignement = attrs._nom_alignement || "centre";
|
||||
var nomPolice = attrs._nom_police || "inherit";
|
||||
var nomTaille = attrs._nom_taille || "12";
|
||||
var nomGras = attrs._nom_gras === "1";
|
||||
var nomItalique = attrs._nom_italique === "1";
|
||||
var flexDir = nomPosition === "cote" ? "row" : "column";
|
||||
var alignItems = nomPosition === "cote" ? "center" : "stretch";
|
||||
var style = forgeStyleString(
|
||||
el, parentFlexDirection, el.widget
|
||||
) + " display:flex; flex-direction:" + flexDir + "; align-items:" + alignItems + "; justify-content:center; gap:4px;";
|
||||
const attrs = el.attributes || {};
|
||||
const nomPosition = attrs._nom_position || 'dessus';
|
||||
const nomAlignement = attrs._nom_alignement || 'centre';
|
||||
const nomPolice = attrs._nom_police || 'inherit';
|
||||
const nomTaille = attrs._nom_taille || '12';
|
||||
const nomGras = attrs._nom_gras === '1';
|
||||
const nomItalique = attrs._nom_italique === '1';
|
||||
const flexDir = nomPosition === 'cote' ? 'row' : 'column';
|
||||
const alignItems = nomPosition === 'cote' ? 'center' : 'stretch';
|
||||
const style = `${forgeStyleString(el, parentFlexDirection, el.widget)} display:flex; flex-direction:${flexDir}; align-items:${alignItems}; justify-content:center; gap:4px;`;
|
||||
|
||||
var definitionId = attrs._definition_id;
|
||||
var rowId = attrs._row_id;
|
||||
var fieldName = (attrs._champ || "").trim();
|
||||
var fieldNameNom = (attrs._champ_nom || "").trim();
|
||||
var minV = parseFloat(attrs._min); if (isNaN(minV)) minV = 0;
|
||||
var maxV = parseFloat(attrs._max); if (isNaN(maxV)) maxV = 100;
|
||||
var couleurBasse = attrs._couleur_basse || "#e05252";
|
||||
var couleurHaute = attrs._couleur_haute || "#4caf50";
|
||||
var afficherValeur = attrs._afficher_valeur === "1";
|
||||
const definitionId = attrs._definition_id;
|
||||
const rowId = attrs._row_id;
|
||||
const fieldName = (attrs._champ || '').trim();
|
||||
const fieldNameNom = (attrs._champ_nom || '').trim();
|
||||
let minV = parseFloat(attrs._min); if (Number.isNaN(minV)) minV = 0;
|
||||
let maxV = parseFloat(attrs._max); if (Number.isNaN(maxV)) maxV = 100;
|
||||
const couleurBasse = attrs._couleur_basse || '#e05252';
|
||||
const couleurHaute = attrs._couleur_haute || '#4caf50';
|
||||
const afficherValeur = attrs._afficher_valeur === '1';
|
||||
|
||||
var rawValue = fieldName ? forgeReadFieldValue(gameData, definitionId, rowId, fieldName) : null;
|
||||
var value = parseFloat(rawValue);
|
||||
if (isNaN(value)) value = minV;
|
||||
const rawValue = fieldName ? forgeReadFieldValue(gameData, definitionId, rowId, fieldName) : null;
|
||||
let value = parseFloat(rawValue);
|
||||
if (Number.isNaN(value)) value = minV;
|
||||
|
||||
var span = (maxV - minV) || 1;
|
||||
var ratio = Math.max(0, Math.min(1, (value - minV) / span));
|
||||
var percent = Math.round(ratio * 1000) / 10;
|
||||
var fillColor = forgeInterpolateColor(couleurBasse, couleurHaute, ratio);
|
||||
const span = (maxV - minV) || 1;
|
||||
const ratio = Math.max(0, Math.min(1, (value - minV) / span));
|
||||
const percent = Math.round(ratio * 1000) / 10;
|
||||
const fillColor = forgeInterpolateColor(couleurBasse, couleurHaute, ratio);
|
||||
|
||||
var labelHtml = "";
|
||||
let labelHtml = '';
|
||||
if (afficherValeur) {
|
||||
labelHtml =
|
||||
'<div style="position:absolute; inset:0; display:flex; align-items:center; ' +
|
||||
'justify-content:center; font-family:system-ui,sans-serif; font-size:12px; ' +
|
||||
'color:#e8eaf0; text-shadow:0 1px 2px rgba(0,0,0,.6);">' +
|
||||
forgeHtmlEscape(rawValue !== null && rawValue !== undefined ? String(rawValue) : "—") + '</div>';
|
||||
labelHtml = '<div style="position:absolute; inset:0; display:flex; align-items:center; '
|
||||
+ 'justify-content:center; font-family:system-ui,sans-serif; font-size:12px; '
|
||||
+ `color:#e8eaf0; text-shadow:0 1px 2px rgba(0,0,0,.6);">${
|
||||
forgeHtmlEscape(rawValue !== null && rawValue !== undefined ? String(rawValue) : '—')}</div>`;
|
||||
}
|
||||
|
||||
var nameHtml = "";
|
||||
let nameHtml = '';
|
||||
if (fieldNameNom) {
|
||||
var nomValue = forgeReadFieldValue(gameData, definitionId, rowId, fieldNameNom);
|
||||
if (nomValue !== null && nomValue !== undefined && nomValue !== "") {
|
||||
var textAlign = (nomPosition === "dessus" && nomAlignement === "gauche") ? "left" : "center";
|
||||
nameHtml =
|
||||
'<div style="flex:0 0 auto; font-family:' + forgeHtmlEscape(nomPolice) + '; ' +
|
||||
'font-size:' + forgeHtmlEscape(String(nomTaille)) + 'px; ' +
|
||||
'font-weight:' + (nomGras ? "700" : "400") + '; ' +
|
||||
'font-style:' + (nomItalique ? "italic" : "normal") + '; ' +
|
||||
'color:#e8eaf0; text-align:' + textAlign + ';">' + forgeHtmlEscape(String(nomValue)) + '</div>';
|
||||
const nomValue = forgeReadFieldValue(gameData, definitionId, rowId, fieldNameNom);
|
||||
if (nomValue !== null && nomValue !== undefined && nomValue !== '') {
|
||||
const textAlign = (nomPosition === 'dessus' && nomAlignement === 'gauche') ? 'left' : 'center';
|
||||
nameHtml = `<div style="flex:0 0 auto; font-family:${forgeHtmlEscape(nomPolice)}; `
|
||||
+ `font-size:${forgeHtmlEscape(String(nomTaille))}px; `
|
||||
+ `font-weight:${nomGras ? '700' : '400'}; `
|
||||
+ `font-style:${nomItalique ? 'italic' : 'normal'}; `
|
||||
+ `color:#e8eaf0; text-align:${textAlign};">${forgeHtmlEscape(String(nomValue))}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
var extraClasses = Object.keys(attrs)
|
||||
.filter(function (k) { return k.indexOf("_class_") === 0 && attrs[k]; })
|
||||
.map(function (k) { return attrs[k]; }).join(" ");
|
||||
var barClass = forgeHtmlEscape(("progress jaugeBar " + extraClasses).trim());
|
||||
var bar =
|
||||
'<progress class="' + barClass + '" value="' + percent + '" max="100" ' +
|
||||
'style="--bulma-progress-value-background-color:' + fillColor + '; --bulma-progress-bar-background-color:#1c2130; ' +
|
||||
'margin:0; border-radius:6px; transition:background-color .3s ease;">' + percent + '%</progress>';
|
||||
var barWrap =
|
||||
'<div style="position:relative; display:flex; align-items:center; ' +
|
||||
'flex:1 1 auto; min-height:14px;">' + bar + labelHtml + '</div>';
|
||||
return '<div id="' + forgeAutoId(el) + '" data-element-id="' + el.id + '" style="' + forgeHtmlEscape(style) + '">' +
|
||||
nameHtml + barWrap + '</div>';
|
||||
const extraClasses = Object.keys(attrs)
|
||||
.filter((k) => k.indexOf('_class_') === 0 && attrs[k])
|
||||
.map((k) => attrs[k]).join(' ');
|
||||
const barClass = forgeHtmlEscape((`progress jaugeBar ${extraClasses}`).trim());
|
||||
const bar = `<progress class="${barClass}" value="${percent}" max="100" `
|
||||
+ `style="--bulma-progress-value-background-color:${fillColor}; --bulma-progress-bar-background-color:#1c2130; `
|
||||
+ `margin:0; border-radius:6px; transition:background-color .3s ease;">${percent}%</progress>`;
|
||||
const barWrap = '<div style="position:relative; display:flex; align-items:center; '
|
||||
+ `flex:1 1 auto; min-height:14px;">${bar}${labelHtml}</div>`;
|
||||
return `<div id="${forgeAutoId(el)}" data-element-id="${el.id}" style="${forgeHtmlEscape(style)}">${
|
||||
nameHtml}${barWrap}</div>`;
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = { forgeRenderJauge, forgeHexToRgb, forgeInterpolateColor, forgeReadFieldValue };
|
||||
var renderMod = require('./render-element.js');
|
||||
module.exports = {
|
||||
forgeRenderJauge, forgeHexToRgb, forgeInterpolateColor, forgeReadFieldValue,
|
||||
};
|
||||
const renderMod = require('./render-element.js');
|
||||
global.forgeStyleString = global.forgeStyleString || renderMod.forgeStyleString;
|
||||
global.forgeHtmlEscape = global.forgeHtmlEscape || renderMod.forgeHtmlEscape;
|
||||
global.forgeAutoId = global.forgeAutoId || renderMod.forgeAutoId;
|
||||
|
||||
@@ -4,47 +4,46 @@
|
||||
// dans l'éditeur (non pertinent hors ligne, qui ne sert jamais l'éditeur,
|
||||
// mais le port reste fidèle pour ne pas diverger inutilement).
|
||||
function forgeRenderOverlay(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
|
||||
var isPlayMode = !!(ctx && ctx._forge_play_mode);
|
||||
var attrsRaw = el.attributes || {};
|
||||
var boxColor = attrsRaw._couleur_boite || "#1f2430";
|
||||
var radius = attrsRaw._arrondi || "12";
|
||||
const isPlayMode = !!(ctx && ctx._forge_play_mode);
|
||||
const attrsRaw = el.attributes || {};
|
||||
const boxColor = attrsRaw._couleur_boite || '#1f2430';
|
||||
const radius = attrsRaw._arrondi || '12';
|
||||
|
||||
var attrs = forgeVisibleAttrs(el, meta, ctx);
|
||||
var style, wrapperClass;
|
||||
const attrs = forgeVisibleAttrs(el, meta, ctx);
|
||||
let style; let
|
||||
wrapperClass;
|
||||
if (isPlayMode) {
|
||||
var baseOverlayStyle =
|
||||
"position:fixed; inset:0; z-index:9999; display:flex; " +
|
||||
"align-items:center; justify-content:center; background:rgba(0,0,0,0.6);";
|
||||
style = baseOverlayStyle + " " + forgeStyleString(el, parentFlexDirection, el.widget);
|
||||
wrapperClass = "modal is-active";
|
||||
const baseOverlayStyle = 'position:fixed; inset:0; z-index:9999; display:flex; '
|
||||
+ 'align-items:center; justify-content:center; background:rgba(0,0,0,0.6);';
|
||||
style = `${baseOverlayStyle} ${forgeStyleString(el, parentFlexDirection, el.widget)}`;
|
||||
wrapperClass = 'modal is-active';
|
||||
} else {
|
||||
style = forgeStyleString(el, parentFlexDirection, el.widget);
|
||||
wrapperClass = "";
|
||||
wrapperClass = '';
|
||||
}
|
||||
|
||||
var attrParts = ['style="' + forgeHtmlEscape(style) + '"'];
|
||||
Object.keys(attrs).forEach(function (k) {
|
||||
var v = attrs[k];
|
||||
if (v === null || v === undefined || v === "" || v === false) return;
|
||||
attrParts.push(v === true ? forgeHtmlEscape(k) : forgeHtmlEscape(k) + '="' + forgeHtmlEscape(v) + '"');
|
||||
const attrParts = [`style="${forgeHtmlEscape(style)}"`];
|
||||
Object.keys(attrs).forEach((k) => {
|
||||
const v = attrs[k];
|
||||
if (v === null || v === undefined || v === '' || v === false) return;
|
||||
attrParts.push(v === true ? forgeHtmlEscape(k) : `${forgeHtmlEscape(k)}="${forgeHtmlEscape(v)}"`);
|
||||
});
|
||||
|
||||
var boxStyle =
|
||||
"background:" + forgeHtmlEscape(String(boxColor)) + "; border-radius:" + forgeHtmlEscape(String(radius)) + "px; " +
|
||||
"color:#e8eaf0; padding:24px; width:100%; max-width:min(560px, 90%); max-height:90%; overflow:auto; box-sizing:border-box;";
|
||||
var childHtml = forgeRenderChildren(el, gameData, childrenMap, ctx);
|
||||
var wrapperClassAttr = wrapperClass ? 'class="' + wrapperClass + '" ' : "";
|
||||
const boxStyle = `background:${forgeHtmlEscape(String(boxColor))}; border-radius:${forgeHtmlEscape(String(radius))}px; `
|
||||
+ 'color:#e8eaf0; padding:24px; width:100%; max-width:min(560px, 90%); max-height:90%; overflow:auto; box-sizing:border-box;';
|
||||
const childHtml = forgeRenderChildren(el, gameData, childrenMap, ctx);
|
||||
const wrapperClassAttr = wrapperClass ? `class="${wrapperClass}" ` : '';
|
||||
return (
|
||||
'<div ' + wrapperClassAttr + attrParts.join(" ") + '>' +
|
||||
'<div class="box forgeOverlayBox" style="' + forgeHtmlEscape(boxStyle) + '">' + childHtml + '</div>' +
|
||||
'</div>'
|
||||
`<div ${wrapperClassAttr}${attrParts.join(' ')}>`
|
||||
+ `<div class="box forgeOverlayBox" style="${forgeHtmlEscape(boxStyle)}">${childHtml}</div>`
|
||||
+ '</div>'
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = { forgeRenderOverlay };
|
||||
var renderMod = require('./render-element.js');
|
||||
["forgeVisibleAttrs", "forgeStyleString", "forgeHtmlEscape", "forgeRenderChildren"].forEach(function (name) {
|
||||
const renderMod = require('./render-element.js');
|
||||
['forgeVisibleAttrs', 'forgeStyleString', 'forgeHtmlEscape', 'forgeRenderChildren'].forEach((name) => {
|
||||
global[name] = global[name] || renderMod[name];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,75 +4,71 @@
|
||||
// d'un écran-modèle d'élément de jeu réutilisable, précalculé une fois à
|
||||
// l'export) au lieu de screens/element_types/load_template_tree.py.
|
||||
function forgeRenderRepeater(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
|
||||
var style = forgeStyleString(el, parentFlexDirection, el.widget) + " overflow:auto;";
|
||||
var attrsRaw = el.attributes || {};
|
||||
var definitionId = attrsRaw._definition_id;
|
||||
var definitionAttr = definitionId ? ' data-definition-id="' + forgeHtmlEscape(String(definitionId)) + '"' : "";
|
||||
var templateTypeId = attrsRaw._template_type_id;
|
||||
var template = forgeApplyCtx(el.content || "", ctx);
|
||||
var children = (childrenMap && childrenMap[el.id]) || [];
|
||||
const style = `${forgeStyleString(el, parentFlexDirection, el.widget)} overflow:auto;`;
|
||||
const attrsRaw = el.attributes || {};
|
||||
let definitionId = attrsRaw._definition_id;
|
||||
const definitionAttr = definitionId ? ` data-definition-id="${forgeHtmlEscape(String(definitionId))}"` : '';
|
||||
const templateTypeId = attrsRaw._template_type_id;
|
||||
const template = forgeApplyCtx(el.content || '', ctx);
|
||||
const children = (childrenMap && childrenMap[el.id]) || [];
|
||||
|
||||
if (!definitionId) {
|
||||
return '<div id="' + forgeAutoId(el) + '" data-element-id="' + el.id + '" style="' + forgeHtmlEscape(style) +
|
||||
'"><em>Répéteur : choisis un objet de données dans les propriétés.</em></div>';
|
||||
return `<div id="${forgeAutoId(el)}" data-element-id="${el.id}" style="${forgeHtmlEscape(style)
|
||||
}"><em>Répéteur : choisis un objet de données dans les propriétés.</em></div>`;
|
||||
}
|
||||
definitionId = String(definitionId);
|
||||
var fieldsMetaForDef = gameData.fields_meta && gameData.fields_meta[definitionId];
|
||||
const fieldsMetaForDef = gameData.fields_meta && gameData.fields_meta[definitionId];
|
||||
if (!fieldsMetaForDef) {
|
||||
return '<div id="' + forgeAutoId(el) + '" data-element-id="' + el.id + '" style="' + forgeHtmlEscape(style) +
|
||||
'"><em>Objet introuvable — a-t-il été supprimé ?</em></div>';
|
||||
return `<div id="${forgeAutoId(el)}" data-element-id="${el.id}" style="${forgeHtmlEscape(style)
|
||||
}"><em>Objet introuvable — a-t-il été supprimé ?</em></div>`;
|
||||
}
|
||||
|
||||
var rows = gameData.data[definitionId] || [];
|
||||
let rows = gameData.data[definitionId] || [];
|
||||
rows = forgeFilterRepeaterRows(gameData, definitionId, rows, attrsRaw);
|
||||
|
||||
var templateTree = null;
|
||||
let templateTree = null;
|
||||
if (templateTypeId) {
|
||||
templateTree = gameData.element_type_templates && gameData.element_type_templates[String(templateTypeId)];
|
||||
}
|
||||
|
||||
var items = rows.map(function (row) {
|
||||
var rowCtx = Object.assign({}, ctx || {});
|
||||
const items = rows.map((row) => {
|
||||
const rowCtx = { ...ctx || {} };
|
||||
// gameData.data est déjà indexé par le nom D'AFFICHAGE du champ (voir
|
||||
// full_game_payload.py), jamais par sa colonne SQL — row[f.name] lit
|
||||
// donc directement la bonne valeur, y compris pour un champ "relation"
|
||||
// (voir filter-repeater-rows.js pour le détail de ce piège).
|
||||
fieldsMetaForDef.forEach(function (f) {
|
||||
var v = row[f.name];
|
||||
rowCtx[f.name] = v === null || v === undefined ? "" : String(v);
|
||||
fieldsMetaForDef.forEach((f) => {
|
||||
const v = row[f.name];
|
||||
rowCtx[f.name] = v === null || v === undefined ? '' : String(v);
|
||||
});
|
||||
var inner;
|
||||
let inner;
|
||||
if (templateTree) {
|
||||
inner = templateTree.top.map(function (c) {
|
||||
return forgeRenderElementHtml(c, gameData, templateTree.children_map, rowCtx);
|
||||
}).join("");
|
||||
inner = templateTree.top.map((c) => forgeRenderElementHtml(c, gameData, templateTree.children_map, rowCtx)).join('');
|
||||
} else if (children.length) {
|
||||
inner = children.map(function (c) {
|
||||
return forgeRenderElementHtml(c, gameData, childrenMap, rowCtx);
|
||||
}).join("");
|
||||
inner = children.map((c) => forgeRenderElementHtml(c, gameData, childrenMap, rowCtx)).join('');
|
||||
} else {
|
||||
var line = template;
|
||||
Object.keys(rowCtx).forEach(function (fname) {
|
||||
if (fname === "_forge_play_mode") return;
|
||||
line = line.split("{{" + fname + "}}").join(rowCtx[fname]);
|
||||
let line = template;
|
||||
Object.keys(rowCtx).forEach((fname) => {
|
||||
if (fname === '_forge_play_mode') return;
|
||||
line = line.split(`{{${fname}}}`).join(rowCtx[fname]);
|
||||
});
|
||||
inner = forgeHtmlEscape(line);
|
||||
}
|
||||
return '<div class="repeaterItem" data-row-id="' + row.id + '">' + inner + '</div>';
|
||||
return `<div class="repeaterItem" data-row-id="${row.id}">${inner}</div>`;
|
||||
});
|
||||
|
||||
var emptyMsg = attrsRaw._filtre_champ ? "Aucune ligne ne correspond au filtre réglé." : "Aucune donnée dans cet objet pour l'instant.";
|
||||
var innerAll = items.length ? items.join("") : "<em>" + emptyMsg + "</em>";
|
||||
return '<div id="' + forgeAutoId(el) + '" data-element-id="' + el.id + '"' + definitionAttr +
|
||||
' style="' + forgeHtmlEscape(style) + '">' + innerAll + '</div>';
|
||||
const emptyMsg = attrsRaw._filtre_champ ? 'Aucune ligne ne correspond au filtre réglé.' : "Aucune donnée dans cet objet pour l'instant.";
|
||||
const innerAll = items.length ? items.join('') : `<em>${emptyMsg}</em>`;
|
||||
return `<div id="${forgeAutoId(el)}" data-element-id="${el.id}"${definitionAttr
|
||||
} style="${forgeHtmlEscape(style)}">${innerAll}</div>`;
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = { forgeRenderRepeater };
|
||||
var renderMod = require('./render-element.js');
|
||||
["forgeStyleString", "forgeHtmlEscape", "forgeAutoId", "forgeApplyCtx", "forgeRenderElementHtml"].forEach(function (name) {
|
||||
const renderMod = require('./render-element.js');
|
||||
['forgeStyleString', 'forgeHtmlEscape', 'forgeAutoId', 'forgeApplyCtx', 'forgeRenderElementHtml'].forEach((name) => {
|
||||
global[name] = global[name] || renderMod[name];
|
||||
});
|
||||
var filterMod = require('./filter-repeater-rows.js');
|
||||
const filterMod = require('./filter-repeater-rows.js');
|
||||
global.forgeFilterRepeaterRows = global.forgeFilterRepeaterRows || filterMod.forgeFilterRepeaterRows;
|
||||
}
|
||||
|
||||
@@ -9,57 +9,56 @@
|
||||
// forgeParentFlexDirection/forgeRenderChildren), chargé avant ce fichier.
|
||||
|
||||
function forgeRenderCheckboxOrRadio(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
|
||||
var inputType = el.widget === "case_a_cocher" ? "checkbox" : "radio";
|
||||
var attrs = forgeVisibleAttrs(el, meta, ctx);
|
||||
const inputType = el.widget === 'case_a_cocher' ? 'checkbox' : 'radio';
|
||||
const attrs = forgeVisibleAttrs(el, meta, ctx);
|
||||
attrs.type = inputType;
|
||||
var style = forgeStyleString(el, parentFlexDirection, el.widget) + " display:flex; align-items:center; gap:8px;";
|
||||
var content = forgeApplyCtx(el.content || "", ctx);
|
||||
return '<label ' + forgeAttrString({ class: inputType }, style) + '><input ' + forgeAttrString(attrs, "") + '> ' + forgeHtmlEscape(content) + '</label>';
|
||||
const style = `${forgeStyleString(el, parentFlexDirection, el.widget)} display:flex; align-items:center; gap:8px;`;
|
||||
const content = forgeApplyCtx(el.content || '', ctx);
|
||||
return `<label ${forgeAttrString({ class: inputType }, style)}><input ${forgeAttrString(attrs, '')}> ${forgeHtmlEscape(content)}</label>`;
|
||||
}
|
||||
|
||||
function forgeRenderSelect(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
|
||||
var outerAttrs = forgeVisibleAttrs(el, meta, ctx);
|
||||
var bulmaClasses = outerAttrs.class || "";
|
||||
var selectAttrs = {}, wrapperAttrs = {};
|
||||
Object.keys(outerAttrs).forEach(function (k) {
|
||||
if (k === "id" || k === "data-element-id" || k === "data-anim-target") wrapperAttrs[k] = outerAttrs[k];
|
||||
else if (k !== "class") selectAttrs[k] = outerAttrs[k];
|
||||
const outerAttrs = forgeVisibleAttrs(el, meta, ctx);
|
||||
const bulmaClasses = outerAttrs.class || '';
|
||||
const selectAttrs = {}; const
|
||||
wrapperAttrs = {};
|
||||
Object.keys(outerAttrs).forEach((k) => {
|
||||
if (k === 'id' || k === 'data-element-id' || k === 'data-anim-target') wrapperAttrs[k] = outerAttrs[k];
|
||||
else if (k !== 'class') selectAttrs[k] = outerAttrs[k];
|
||||
});
|
||||
var style = forgeStyleString(el, parentFlexDirection, el.widget) + " display:flex;";
|
||||
var content = forgeApplyCtx(el.content || "", ctx);
|
||||
var options = content.split("\n").map(function (o) { return o.trim(); }).filter(Boolean);
|
||||
var optsHtml = options.map(function (o) {
|
||||
var esc = forgeHtmlEscape(o);
|
||||
return '<option value="' + esc + '">' + esc + '</option>';
|
||||
}).join("");
|
||||
var selectHtml = '<select ' + forgeAttrString(selectAttrs, "") + '>' + optsHtml + '</select>';
|
||||
var selectWrapClass = forgeHtmlEscape(("select is-fullwidth " + bulmaClasses).trim());
|
||||
return '<div ' + forgeAttrString(wrapperAttrs, style) + '><div class="' + selectWrapClass + '">' + selectHtml + '</div></div>';
|
||||
const style = `${forgeStyleString(el, parentFlexDirection, el.widget)} display:flex;`;
|
||||
const content = forgeApplyCtx(el.content || '', ctx);
|
||||
const options = content.split('\n').map((o) => o.trim()).filter(Boolean);
|
||||
const optsHtml = options.map((o) => {
|
||||
const esc = forgeHtmlEscape(o);
|
||||
return `<option value="${esc}">${esc}</option>`;
|
||||
}).join('');
|
||||
const selectHtml = `<select ${forgeAttrString(selectAttrs, '')}>${optsHtml}</select>`;
|
||||
const selectWrapClass = forgeHtmlEscape((`select is-fullwidth ${bulmaClasses}`).trim());
|
||||
return `<div ${forgeAttrString(wrapperAttrs, style)}><div class="${selectWrapClass}">${selectHtml}</div></div>`;
|
||||
}
|
||||
|
||||
function forgeRenderFieldset(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
|
||||
var attrs = forgeVisibleAttrs(el, meta, ctx);
|
||||
var style = forgeStyleString(el, parentFlexDirection, el.widget);
|
||||
var content = forgeApplyCtx(el.content || "", ctx) || "Groupe de champs";
|
||||
var childHtml = forgeRenderChildren(el, gameData, childrenMap, ctx);
|
||||
return '<fieldset ' + forgeAttrString(attrs, style) + '><legend>' + forgeHtmlEscape(content) + '</legend>' + childHtml + '</fieldset>';
|
||||
const attrs = forgeVisibleAttrs(el, meta, ctx);
|
||||
const style = forgeStyleString(el, parentFlexDirection, el.widget);
|
||||
const content = forgeApplyCtx(el.content || '', ctx) || 'Groupe de champs';
|
||||
const childHtml = forgeRenderChildren(el, gameData, childrenMap, ctx);
|
||||
return `<fieldset ${forgeAttrString(attrs, style)}><legend>${forgeHtmlEscape(content)}</legend>${childHtml}</fieldset>`;
|
||||
}
|
||||
|
||||
function forgeRenderTable(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
|
||||
var attrs = forgeVisibleAttrs(el, meta, ctx);
|
||||
var headerRow = ["1", "true", "on"].indexOf((el.attributes || {})._header_row) !== -1;
|
||||
var content = forgeApplyCtx(el.content || "", ctx);
|
||||
var style = forgeStyleString(el, parentFlexDirection, el.widget);
|
||||
var rows = content.split("\n").filter(function (r) { return r.trim(); });
|
||||
var htmlRows = rows.map(function (r, i) {
|
||||
var cells = r.split(";");
|
||||
var cellTag = (headerRow && i === 0) ? "th" : "td";
|
||||
var cellsHtml = cells.map(function (c) {
|
||||
return "<" + cellTag + ">" + forgeHtmlEscape(c.trim()) + "</" + cellTag + ">";
|
||||
}).join("");
|
||||
return "<tr>" + cellsHtml + "</tr>";
|
||||
}).join("");
|
||||
return '<table ' + forgeAttrString(attrs, style) + '>' + htmlRows + '</table>';
|
||||
const attrs = forgeVisibleAttrs(el, meta, ctx);
|
||||
const headerRow = ['1', 'true', 'on'].indexOf((el.attributes || {})._header_row) !== -1;
|
||||
const content = forgeApplyCtx(el.content || '', ctx);
|
||||
const style = forgeStyleString(el, parentFlexDirection, el.widget);
|
||||
const rows = content.split('\n').filter((r) => r.trim());
|
||||
const htmlRows = rows.map((r, i) => {
|
||||
const cells = r.split(';');
|
||||
const cellTag = (headerRow && i === 0) ? 'th' : 'td';
|
||||
const cellsHtml = cells.map((c) => `<${cellTag}>${forgeHtmlEscape(c.trim())}</${cellTag}>`).join('');
|
||||
return `<tr>${cellsHtml}</tr>`;
|
||||
}).join('');
|
||||
return `<table ${forgeAttrString(attrs, style)}>${htmlRows}</table>`;
|
||||
}
|
||||
|
||||
function forgeRenderIcone(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
|
||||
@@ -67,12 +66,12 @@ function forgeRenderIcone(el, meta, gameData, childrenMap, ctx, parentFlexDirect
|
||||
// catalogue curaté (screens/widgets/fontawesome_icons.py) : une valeur
|
||||
// invalide ne peut être enregistrée que depuis l'éditeur, qui la valide
|
||||
// déjà à la sauvegarde — un jeu EXPORTÉ n'en produit jamais.
|
||||
var rawAttrs = el.attributes || {};
|
||||
var iconSlug = rawAttrs._icon_slug || "star";
|
||||
var attrs = forgeVisibleAttrs(el, meta, ctx);
|
||||
var existingClass = attrs.class || "";
|
||||
const rawAttrs = el.attributes || {};
|
||||
const iconSlug = rawAttrs._icon_slug || 'star';
|
||||
const attrs = forgeVisibleAttrs(el, meta, ctx);
|
||||
const existingClass = attrs.class || '';
|
||||
delete attrs.class;
|
||||
attrs.class = (existingClass + " icon-svg").trim();
|
||||
attrs.class = (`${existingClass} icon-svg`).trim();
|
||||
// data: URI pré-calculée à l'export (voir gameData.icon_data_uris,
|
||||
// publish/build_scorm_package.py::_build_icon_data_uris) — jamais un
|
||||
// chemin de fichier, même relatif : Firefox traite chaque ressource
|
||||
@@ -84,49 +83,50 @@ function forgeRenderIcone(el, meta, gameData, childrenMap, ctx, parentFlexDirect
|
||||
// devrait pas arriver : _collect_icon_slugs scanne tous les widgets
|
||||
// "icone", posés directement ou nichés dans un élément de jeu
|
||||
// réutilisable) — au moins un lien exploitable plutôt qu'un vide.
|
||||
var iconUrl = (gameData.icon_data_uris && gameData.icon_data_uris[iconSlug]) || ("icons/" + iconSlug + ".svg");
|
||||
var style = forgeStyleString(el, parentFlexDirection, el.widget) + " --icon-url:url('" + iconUrl + "');";
|
||||
const iconUrl = (gameData.icon_data_uris && gameData.icon_data_uris[iconSlug]) || (`icons/${iconSlug}.svg`);
|
||||
const style = `${forgeStyleString(el, parentFlexDirection, el.widget)} --icon-url:url('${iconUrl}');`;
|
||||
// aria-hidden (RGAA/WCAG 1.1.1) : une icône seule (pas de champ de
|
||||
// description dédié dans l'éditeur) est traitée comme purement
|
||||
// décorative — jamais annoncée par un lecteur d'écran, comme le nom du
|
||||
// slug FontAwesome ("star") n'aurait aucun sens lu à voix haute.
|
||||
if (!("aria-hidden" in attrs)) attrs["aria-hidden"] = "true";
|
||||
return "<i " + forgeAttrString(attrs, style) + "></i>";
|
||||
if (!('aria-hidden' in attrs)) attrs['aria-hidden'] = 'true';
|
||||
return `<i ${forgeAttrString(attrs, style)}></i>`;
|
||||
}
|
||||
|
||||
function forgeRenderOnglets(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
|
||||
var tabs = (childrenMap && childrenMap[el.id]) || [];
|
||||
var attrs = forgeVisibleAttrs(el, meta, ctx);
|
||||
var tabsExtraClasses = attrs.class || "";
|
||||
const tabs = (childrenMap && childrenMap[el.id]) || [];
|
||||
const attrs = forgeVisibleAttrs(el, meta, ctx);
|
||||
const tabsExtraClasses = attrs.class || '';
|
||||
delete attrs.class;
|
||||
var style = forgeStyleString(el, parentFlexDirection, el.widget) + " display:flex; flex-direction:column;";
|
||||
var groupId = forgeAutoId(el);
|
||||
const style = `${forgeStyleString(el, parentFlexDirection, el.widget)} display:flex; flex-direction:column;`;
|
||||
const groupId = forgeAutoId(el);
|
||||
|
||||
if (!tabs.length) {
|
||||
return '<div ' + forgeAttrString(attrs, style) + '><em style="opacity:.6;">Onglets — ajoute au moins un onglet dans ses réglages.</em></div>';
|
||||
return `<div ${forgeAttrString(attrs, style)}><em style="opacity:.6;">Onglets — ajoute au moins un onglet dans ses réglages.</em></div>`;
|
||||
}
|
||||
|
||||
var flexDir = forgeParentFlexDirection(el);
|
||||
var barItems = [], panels = [];
|
||||
tabs.forEach(function (tab, i) {
|
||||
var label = tab.name || ("Onglet " + (i + 1));
|
||||
var active = i === 0;
|
||||
var tabHtml = forgeRenderElementHtml(tab, gameData, childrenMap, ctx, flexDir);
|
||||
var targetId = "tabpanel-" + tab.id;
|
||||
const flexDir = forgeParentFlexDirection(el);
|
||||
const barItems = []; const
|
||||
panels = [];
|
||||
tabs.forEach((tab, i) => {
|
||||
const label = tab.name || (`Onglet ${i + 1}`);
|
||||
const active = i === 0;
|
||||
const tabHtml = forgeRenderElementHtml(tab, gameData, childrenMap, ctx, flexDir);
|
||||
const targetId = `tabpanel-${tab.id}`;
|
||||
barItems.push(
|
||||
'<li class="' + (active ? "is-active" : "") + '" data-tab-btn="' + groupId + '" data-tab-target="' + targetId +
|
||||
'" onclick="forgeShowTab(this)"><a>' + forgeHtmlEscape(String(label)) + '</a></li>'
|
||||
`<li class="${active ? 'is-active' : ''}" data-tab-btn="${groupId}" data-tab-target="${targetId
|
||||
}" onclick="forgeShowTab(this)"><a>${forgeHtmlEscape(String(label))}</a></li>`,
|
||||
);
|
||||
var hiddenStyle = active ? "" : ' style="display:none;"';
|
||||
panels.push('<div class="forgeTabPanel" id="' + targetId + '" data-tab-group="' + groupId + '"' + hiddenStyle + '>' + tabHtml + '</div>');
|
||||
const hiddenStyle = active ? '' : ' style="display:none;"';
|
||||
panels.push(`<div class="forgeTabPanel" id="${targetId}" data-tab-group="${groupId}"${hiddenStyle}>${tabHtml}</div>`);
|
||||
});
|
||||
|
||||
var tabsClass = forgeHtmlEscape(("tabs " + tabsExtraClasses).trim());
|
||||
const tabsClass = forgeHtmlEscape((`tabs ${tabsExtraClasses}`).trim());
|
||||
return (
|
||||
'<div ' + forgeAttrString(attrs, style) + '>' +
|
||||
'<div class="' + tabsClass + '"><ul>' + barItems.join("") + '</ul></div>' +
|
||||
'<div class="forgeTabPanels" style="flex:1 1 auto; min-height:0;">' + panels.join("") + '</div>' +
|
||||
'</div>'
|
||||
`<div ${forgeAttrString(attrs, style)}>`
|
||||
+ `<div class="${tabsClass}"><ul>${barItems.join('')}</ul></div>`
|
||||
+ `<div class="forgeTabPanels" style="flex:1 1 auto; min-height:0;">${panels.join('')}</div>`
|
||||
+ '</div>'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -136,27 +136,32 @@ function forgeRenderOnglets(el, meta, gameData, childrenMap, ctx, parentFlexDire
|
||||
// travail est fait UNE fois, à la génération du paquet, jamais à chaque
|
||||
// rendu côté client.
|
||||
function forgeRenderPersonnage(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
|
||||
var attrs = forgeVisibleAttrs(el, meta, ctx);
|
||||
attrs["data-personnage"] = "1";
|
||||
var animations = gameData.personnage_animations && gameData.personnage_animations[String(el.id)];
|
||||
var frames = animations ? (animations.idle || animations[Object.keys(animations)[0]]) : null;
|
||||
var idleFrame = frames && frames.length ? frames[0] : "";
|
||||
const attrs = forgeVisibleAttrs(el, meta, ctx);
|
||||
attrs['data-personnage'] = '1';
|
||||
const animations = gameData.personnage_animations && gameData.personnage_animations[String(el.id)];
|
||||
const frames = animations ? (animations.idle || animations[Object.keys(animations)[0]]) : null;
|
||||
const idleFrame = frames && frames.length ? frames[0] : '';
|
||||
if (idleFrame) attrs.src = idleFrame;
|
||||
// alt (RGAA/WCAG 1.1.1) : voir render-element.js pour le même défaut
|
||||
// décoratif sur le widget "image" générique.
|
||||
if (!("alt" in attrs)) attrs.alt = "";
|
||||
var style = forgeStyleString(el, parentFlexDirection, el.widget);
|
||||
return "<img " + forgeAttrString(attrs, style) + ">";
|
||||
if (!('alt' in attrs)) attrs.alt = '';
|
||||
const style = forgeStyleString(el, parentFlexDirection, el.widget);
|
||||
return `<img ${forgeAttrString(attrs, style)}>`;
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = {
|
||||
forgeRenderCheckboxOrRadio, forgeRenderSelect, forgeRenderFieldset, forgeRenderTable,
|
||||
forgeRenderIcone, forgeRenderOnglets, forgeRenderPersonnage,
|
||||
forgeRenderCheckboxOrRadio,
|
||||
forgeRenderSelect,
|
||||
forgeRenderFieldset,
|
||||
forgeRenderTable,
|
||||
forgeRenderIcone,
|
||||
forgeRenderOnglets,
|
||||
forgeRenderPersonnage,
|
||||
};
|
||||
var renderMod = require('./render-element.js');
|
||||
["forgeVisibleAttrs", "forgeStyleString", "forgeAttrString", "forgeApplyCtx", "forgeHtmlEscape",
|
||||
"forgeAutoId", "forgeParentFlexDirection", "forgeRenderChildren", "forgeRenderElementHtml"].forEach(function (name) {
|
||||
global[name] = global[name] || renderMod[name];
|
||||
});
|
||||
const renderMod = require('./render-element.js');
|
||||
['forgeVisibleAttrs', 'forgeStyleString', 'forgeAttrString', 'forgeApplyCtx', 'forgeHtmlEscape',
|
||||
'forgeAutoId', 'forgeParentFlexDirection', 'forgeRenderChildren', 'forgeRenderElementHtml'].forEach((name) => {
|
||||
global[name] = global[name] || renderMod[name];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -16,68 +16,73 @@ function forgeScormFindApi(win, triesLeft) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var forgeScormApi = null;
|
||||
var forgeScormLastSync = null; // évite de spammer LMSCommit() si rien n'a changé
|
||||
let forgeScormApi = null;
|
||||
let forgeScormLastSync = null; // évite de spammer LMSCommit() si rien n'a changé
|
||||
|
||||
function forgeScormInit() {
|
||||
forgeScormApi = forgeScormFindApi(window, 10);
|
||||
if (!forgeScormApi) return; // ouvert hors LMS (test local) : dégrade en silence
|
||||
forgeScormApi.LMSInitialize("");
|
||||
forgeScormApi.LMSInitialize('');
|
||||
// cmi.core.score.min/max (voir publish/build_scorm_package.py,
|
||||
// db/dialogue_lines.py::sum_question_rewards) : sans eux, le LMS n'a que le score BRUT
|
||||
// (cmi.core.score.raw, un total de points sans barème) et l'affiche
|
||||
// parfois à tort avec un "%" — en fournissant aussi min/max une seule
|
||||
// fois ici, le LMS peut calculer raw/max lui-même, un vrai pourcentage.
|
||||
// Omis si le jeu n'a aucune question de quiz (max=0, rien à barémer).
|
||||
var maxScore = typeof window !== "undefined" ? window.FORGE_MAX_QUIZ_SCORE : 0;
|
||||
const maxScore = typeof window !== 'undefined' ? window.FORGE_MAX_QUIZ_SCORE : 0;
|
||||
if (maxScore) {
|
||||
forgeScormApi.LMSSetValue("cmi.core.score.min", "0");
|
||||
forgeScormApi.LMSSetValue("cmi.core.score.max", String(maxScore));
|
||||
forgeScormApi.LMSSetValue('cmi.core.score.min', '0');
|
||||
forgeScormApi.LMSSetValue('cmi.core.score.max', String(maxScore));
|
||||
}
|
||||
}
|
||||
|
||||
// Statut Forge (voir db/constants.py, SCORM_STATUS_KEYS) -> vocabulaire
|
||||
// figé cmi.core.lesson_status (SCORM 1.2) : pas de mapping 1-pour-1 pour
|
||||
// "en_cours" (SCORM n'a pas d'équivalent "en cours" hors "incomplete").
|
||||
var FORGE_TO_SCORM_STATUS = {
|
||||
non_commence: "not attempted", en_cours: "incomplete", termine: "completed",
|
||||
reussi: "passed", echoue: "failed",
|
||||
const FORGE_TO_SCORM_STATUS = {
|
||||
non_commence: 'not attempted',
|
||||
en_cours: 'incomplete',
|
||||
termine: 'completed',
|
||||
reussi: 'passed',
|
||||
echoue: 'failed',
|
||||
};
|
||||
|
||||
function forgeScormSyncScore(gameData) {
|
||||
if (!forgeScormApi || !gameData || !gameData.scoring) return;
|
||||
var score = gameData.scoring.score || 0;
|
||||
var status = FORGE_TO_SCORM_STATUS[gameData.scoring.status] || "incomplete";
|
||||
var key = score + "|" + status;
|
||||
const score = gameData.scoring.score || 0;
|
||||
const status = FORGE_TO_SCORM_STATUS[gameData.scoring.status] || 'incomplete';
|
||||
const key = `${score}|${status}`;
|
||||
if (key === forgeScormLastSync) return;
|
||||
forgeScormLastSync = key;
|
||||
forgeScormApi.LMSSetValue("cmi.core.score.raw", String(score));
|
||||
forgeScormApi.LMSSetValue("cmi.core.lesson_status", status);
|
||||
forgeScormApi.LMSCommit("");
|
||||
forgeScormApi.LMSSetValue('cmi.core.score.raw', String(score));
|
||||
forgeScormApi.LMSSetValue('cmi.core.lesson_status', status);
|
||||
forgeScormApi.LMSCommit('');
|
||||
}
|
||||
|
||||
function forgeScormFinish() {
|
||||
if (!forgeScormApi) return;
|
||||
forgeScormApi.LMSFinish("");
|
||||
forgeScormApi.LMSFinish('');
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("load", forgeScormInit);
|
||||
window.addEventListener("beforeunload", forgeScormFinish);
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('load', forgeScormInit);
|
||||
window.addEventListener('beforeunload', forgeScormFinish);
|
||||
// gameData est déjà en mémoire (window.FORGE_OFFLINE) — pas de fetch à
|
||||
// attendre, un intervalle court suffit à refléter score/statut sans
|
||||
// devoir faire dépendre bindings.js de ce fichier optionnel.
|
||||
var forgeScormSyncTimer = setInterval(function () {
|
||||
if (typeof gameData !== "undefined") forgeScormSyncScore(gameData);
|
||||
const forgeScormSyncTimer = setInterval(() => {
|
||||
if (typeof gameData !== 'undefined') forgeScormSyncScore(gameData);
|
||||
}, 2000);
|
||||
// .unref() n'existe pas sur la valeur renvoyée par setInterval() d'un
|
||||
// navigateur (juste un nombre) — seulement sous Node (static/js/play/
|
||||
// offline/__tests__/) : sans ça, `node --test` ne se termine jamais.
|
||||
if (forgeScormSyncTimer && typeof forgeScormSyncTimer.unref === "function") {
|
||||
if (forgeScormSyncTimer && typeof forgeScormSyncTimer.unref === 'function') {
|
||||
forgeScormSyncTimer.unref();
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = { forgeScormFindApi, forgeScormInit, forgeScormSyncScore, forgeScormFinish, FORGE_TO_SCORM_STATUS };
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = {
|
||||
forgeScormFindApi, forgeScormInit, forgeScormSyncScore, forgeScormFinish, FORGE_TO_SCORM_STATUS,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -24,21 +24,21 @@ function forgeScorm2004FindApi(win, triesLeft) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var forgeScorm2004Api = null;
|
||||
var forgeScorm2004LastSync = null; // évite de spammer Commit() si rien n'a changé
|
||||
let forgeScorm2004Api = null;
|
||||
let forgeScorm2004LastSync = null; // évite de spammer Commit() si rien n'a changé
|
||||
|
||||
function forgeScorm2004Init() {
|
||||
forgeScorm2004Api = forgeScorm2004FindApi(window, 10);
|
||||
if (!forgeScorm2004Api) return; // ouvert hors LMS (test local) : dégrade en silence
|
||||
forgeScorm2004Api.Initialize("");
|
||||
forgeScorm2004Api.Initialize('');
|
||||
// cmi.score.min/max (voir static/js/play/offline/scorm-api.js pour le
|
||||
// même réglage côté 1.2) : cmi.score.scaled (forgeScorm2004SyncScore)
|
||||
// couvre déjà le pourcentage correctement, mais min/max reste utile
|
||||
// pour tout LMS qui affiche plutôt raw/max.
|
||||
var maxScore = typeof window !== "undefined" ? window.FORGE_MAX_QUIZ_SCORE : 0;
|
||||
const maxScore = typeof window !== 'undefined' ? window.FORGE_MAX_QUIZ_SCORE : 0;
|
||||
if (maxScore) {
|
||||
forgeScorm2004Api.SetValue("cmi.score.min", "0");
|
||||
forgeScorm2004Api.SetValue("cmi.score.max", String(maxScore));
|
||||
forgeScorm2004Api.SetValue('cmi.score.min', '0');
|
||||
forgeScorm2004Api.SetValue('cmi.score.max', String(maxScore));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,33 +46,33 @@ function forgeScorm2004Init() {
|
||||
// (completion_status, success_status) SCORM 2004 — contrairement à la
|
||||
// 1.2, "echoue" reste "completed" (l'apprenant a fini la formation) avec
|
||||
// success_status="failed", au lieu de retomber en "incomplete".
|
||||
var FORGE_TO_SCORM2004_STATUS = {
|
||||
non_commence: { completion: "not attempted", success: "unknown" },
|
||||
en_cours: { completion: "incomplete", success: "unknown" },
|
||||
termine: { completion: "completed", success: "unknown" },
|
||||
reussi: { completion: "completed", success: "passed" },
|
||||
echoue: { completion: "completed", success: "failed" },
|
||||
const FORGE_TO_SCORM2004_STATUS = {
|
||||
non_commence: { completion: 'not attempted', success: 'unknown' },
|
||||
en_cours: { completion: 'incomplete', success: 'unknown' },
|
||||
termine: { completion: 'completed', success: 'unknown' },
|
||||
reussi: { completion: 'completed', success: 'passed' },
|
||||
echoue: { completion: 'completed', success: 'failed' },
|
||||
};
|
||||
|
||||
function forgeScorm2004SyncScore(gameData) {
|
||||
if (!forgeScorm2004Api || !gameData || !gameData.scoring) return;
|
||||
var score = gameData.scoring.score || 0;
|
||||
var statuses = FORGE_TO_SCORM2004_STATUS[gameData.scoring.status] || FORGE_TO_SCORM2004_STATUS.en_cours;
|
||||
var key = score + "|" + statuses.completion + "|" + statuses.success;
|
||||
const score = gameData.scoring.score || 0;
|
||||
const statuses = FORGE_TO_SCORM2004_STATUS[gameData.scoring.status] || FORGE_TO_SCORM2004_STATUS.en_cours;
|
||||
const key = `${score}|${statuses.completion}|${statuses.success}`;
|
||||
if (key === forgeScorm2004LastSync) return;
|
||||
forgeScorm2004LastSync = key;
|
||||
forgeScorm2004Api.SetValue("cmi.score.raw", String(score));
|
||||
forgeScorm2004Api.SetValue('cmi.score.raw', String(score));
|
||||
// cmi.score.scaled (-1..1, ici toujours 0..1) : uniquement dérivable du
|
||||
// taux de bonnes réponses au quiz (voir dialogue-box-controller.js),
|
||||
// jamais de gameData.scoring.score qui n'est qu'un total de points sans
|
||||
// maximum connu — omis si aucune question n'a encore été répondue.
|
||||
if (typeof window !== "undefined" && window.forgeQuizTotalAnswered) {
|
||||
var scaled = window.forgeQuizCorrectAnswered / window.forgeQuizTotalAnswered;
|
||||
forgeScorm2004Api.SetValue("cmi.score.scaled", String(scaled));
|
||||
if (typeof window !== 'undefined' && window.forgeQuizTotalAnswered) {
|
||||
const scaled = window.forgeQuizCorrectAnswered / window.forgeQuizTotalAnswered;
|
||||
forgeScorm2004Api.SetValue('cmi.score.scaled', String(scaled));
|
||||
}
|
||||
forgeScorm2004Api.SetValue("cmi.completion_status", statuses.completion);
|
||||
forgeScorm2004Api.SetValue("cmi.success_status", statuses.success);
|
||||
forgeScorm2004Api.Commit("");
|
||||
forgeScorm2004Api.SetValue('cmi.completion_status', statuses.completion);
|
||||
forgeScorm2004Api.SetValue('cmi.success_status', statuses.success);
|
||||
forgeScorm2004Api.Commit('');
|
||||
}
|
||||
|
||||
// cmi.interactions.n.* (voir dialogue-box-controller.js::forgeQuizBoxAnswer,
|
||||
@@ -90,47 +90,60 @@ function forgeScorm2004SyncScore(gameData) {
|
||||
// silencieusement (SetValue échoue sans erreur visible : le champ reste
|
||||
// vide). "…T19:49:39" (sans les millisecondes ni le "Z") passe partout.
|
||||
function forgeScorm2004Timestamp() {
|
||||
return new Date().toISOString().replace(/\.\d+Z$/, "");
|
||||
return new Date().toISOString().replace(/\.\d+Z$/, '');
|
||||
}
|
||||
|
||||
function forgeScorm2004NotifyQuestionAnswered(dialogueId, objectName, questionIndex, questionText, correct, choiceIndex, correctIndex) {
|
||||
function forgeScorm2004NotifyQuestionAnswered(
|
||||
dialogueId,
|
||||
objectName,
|
||||
questionIndex,
|
||||
questionText,
|
||||
correct,
|
||||
choiceIndex,
|
||||
correctIndex,
|
||||
) {
|
||||
if (!forgeScorm2004Api) return;
|
||||
var n = parseInt(forgeScorm2004Api.GetValue("cmi.interactions._count"), 10);
|
||||
if (isNaN(n)) n = 0;
|
||||
var prefix = "cmi.interactions." + n + ".";
|
||||
forgeScorm2004Api.SetValue(prefix + "id", "dialogue:" + dialogueId + ":question:" + questionIndex);
|
||||
forgeScorm2004Api.SetValue(prefix + "type", "choice");
|
||||
forgeScorm2004Api.SetValue(prefix + "timestamp", forgeScorm2004Timestamp());
|
||||
forgeScorm2004Api.SetValue(prefix + "description", "{fr-FR}" + (questionText || ""));
|
||||
forgeScorm2004Api.SetValue(prefix + "learner_response", String(choiceIndex));
|
||||
forgeScorm2004Api.SetValue(prefix + "result", correct ? "correct" : "incorrect");
|
||||
forgeScorm2004Api.SetValue(prefix + "correct_responses.0.pattern", String(correctIndex));
|
||||
forgeScorm2004Api.Commit("");
|
||||
let n = parseInt(forgeScorm2004Api.GetValue('cmi.interactions._count'), 10);
|
||||
if (Number.isNaN(n)) n = 0;
|
||||
const prefix = `cmi.interactions.${n}.`;
|
||||
forgeScorm2004Api.SetValue(`${prefix}id`, `dialogue:${dialogueId}:question:${questionIndex}`);
|
||||
forgeScorm2004Api.SetValue(`${prefix}type`, 'choice');
|
||||
forgeScorm2004Api.SetValue(`${prefix}timestamp`, forgeScorm2004Timestamp());
|
||||
forgeScorm2004Api.SetValue(`${prefix}description`, `{fr-FR}${questionText || ''}`);
|
||||
forgeScorm2004Api.SetValue(`${prefix}learner_response`, String(choiceIndex));
|
||||
forgeScorm2004Api.SetValue(`${prefix}result`, correct ? 'correct' : 'incorrect');
|
||||
forgeScorm2004Api.SetValue(`${prefix}correct_responses.0.pattern`, String(correctIndex));
|
||||
forgeScorm2004Api.Commit('');
|
||||
}
|
||||
|
||||
function forgeScorm2004Finish() {
|
||||
if (!forgeScorm2004Api) return;
|
||||
forgeScorm2004Api.Terminate("");
|
||||
forgeScorm2004Api.Terminate('');
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("load", forgeScorm2004Init);
|
||||
window.addEventListener("beforeunload", forgeScorm2004Finish);
|
||||
var forgeScorm2004SyncTimer = setInterval(function () {
|
||||
if (typeof gameData !== "undefined") forgeScorm2004SyncScore(gameData);
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('load', forgeScorm2004Init);
|
||||
window.addEventListener('beforeunload', forgeScorm2004Finish);
|
||||
const forgeScorm2004SyncTimer = setInterval(() => {
|
||||
if (typeof gameData !== 'undefined') forgeScorm2004SyncScore(gameData);
|
||||
}, 2000);
|
||||
// .unref() n'existe pas sur la valeur renvoyée par setInterval() d'un
|
||||
// navigateur (juste un nombre) — seulement sous Node (static/js/play/
|
||||
// offline/__tests__/) : sans ça, `node --test` ne se termine jamais
|
||||
// (l'intervalle garde le process vivant indéfiniment).
|
||||
if (forgeScorm2004SyncTimer && typeof forgeScorm2004SyncTimer.unref === "function") {
|
||||
if (forgeScorm2004SyncTimer && typeof forgeScorm2004SyncTimer.unref === 'function') {
|
||||
forgeScorm2004SyncTimer.unref();
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = {
|
||||
forgeScorm2004FindApi, forgeScorm2004Init, forgeScorm2004SyncScore, forgeScorm2004Finish,
|
||||
forgeScorm2004NotifyQuestionAnswered, forgeScorm2004Timestamp, FORGE_TO_SCORM2004_STATUS,
|
||||
forgeScorm2004FindApi,
|
||||
forgeScorm2004Init,
|
||||
forgeScorm2004SyncScore,
|
||||
forgeScorm2004Finish,
|
||||
forgeScorm2004NotifyQuestionAnswered,
|
||||
forgeScorm2004Timestamp,
|
||||
FORGE_TO_SCORM2004_STATUS,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,38 +11,38 @@
|
||||
// serveur : sans ça, tous les exemplaires d'un même modèle de ligne
|
||||
// évalueraient la MÊME ligne au lieu de chacun la sienne).
|
||||
function forgeIsElementVisibleOffline(gameData, attrs, ctx) {
|
||||
var mode = attrs._visibilite_cond_mode;
|
||||
if (mode !== "variable" && mode !== "objet") return true;
|
||||
var operator = attrs._visibilite_cond_operateur || "egal";
|
||||
var expected = forgeResolveFilterValue(gameData, attrs._visibilite_cond_valeur || "");
|
||||
const mode = attrs._visibilite_cond_mode;
|
||||
if (mode !== 'variable' && mode !== 'objet') return true;
|
||||
const operator = attrs._visibilite_cond_operateur || 'egal';
|
||||
const expected = forgeResolveFilterValue(gameData, attrs._visibilite_cond_valeur || '');
|
||||
|
||||
if (mode === "variable") {
|
||||
var varName = attrs._visibilite_cond_variable;
|
||||
if (mode === 'variable') {
|
||||
const varName = attrs._visibilite_cond_variable;
|
||||
if (!varName) return true;
|
||||
var variable = gameData.variables && gameData.variables[varName];
|
||||
const variable = gameData.variables && gameData.variables[varName];
|
||||
if (!variable) return true; // variable supprimée depuis : ne bloque jamais l'affichage
|
||||
var value = variable.value;
|
||||
var path = attrs._visibilite_cond_variable_chemin;
|
||||
let { value } = variable;
|
||||
const path = attrs._visibilite_cond_variable_chemin;
|
||||
if (path) value = forgeResolveVariablePath(value, path);
|
||||
return compareValues(value, operator, expected, variable.type || "texte");
|
||||
return compareValues(value, operator, expected, variable.type || 'texte');
|
||||
}
|
||||
|
||||
var definitionId = attrs._visibilite_cond_definition_id;
|
||||
var fieldName = attrs._visibilite_cond_champ;
|
||||
let definitionId = attrs._visibilite_cond_definition_id;
|
||||
const fieldName = attrs._visibilite_cond_champ;
|
||||
if (!definitionId || !fieldName) return true;
|
||||
definitionId = String(definitionId);
|
||||
var fieldsMetaForDef = gameData.fields_meta && gameData.fields_meta[definitionId];
|
||||
const fieldsMetaForDef = gameData.fields_meta && gameData.fields_meta[definitionId];
|
||||
if (!fieldsMetaForDef) return true;
|
||||
var field = fieldsMetaForDef.find(function (f) { return f.name === fieldName; });
|
||||
var fieldType = field ? field.type : "texte";
|
||||
const field = fieldsMetaForDef.find((f) => f.name === fieldName);
|
||||
const fieldType = field ? field.type : 'texte';
|
||||
|
||||
if (ctx && Object.prototype.hasOwnProperty.call(ctx, fieldName)) {
|
||||
return compareValues(ctx[fieldName], operator, expected, fieldType);
|
||||
}
|
||||
|
||||
var rows = gameData.data && gameData.data[definitionId];
|
||||
const rows = gameData.data && gameData.data[definitionId];
|
||||
if (!rows || !rows.length) return true; // objet sans donnée pour l'instant : ne bloque pas
|
||||
var row = rows[0]; // convention "état de partie" : une seule ligne
|
||||
const row = rows[0]; // convention "état de partie" : une seule ligne
|
||||
// row[fieldName] : gameData.data est déjà indexé par le nom D'AFFICHAGE
|
||||
// du champ (voir full_game_payload.py), jamais par sa colonne SQL.
|
||||
return compareValues(row[fieldName], operator, expected, fieldType);
|
||||
@@ -50,7 +50,7 @@ function forgeIsElementVisibleOffline(gameData, attrs, ctx) {
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = { forgeIsElementVisibleOffline };
|
||||
var filterMod = require('./filter-repeater-rows.js');
|
||||
const filterMod = require('./filter-repeater-rows.js');
|
||||
global.forgeResolveFilterValue = global.forgeResolveFilterValue || filterMod.forgeResolveFilterValue;
|
||||
global.forgeResolveVariablePath = global.forgeResolveVariablePath || filterMod.forgeResolveVariablePath;
|
||||
global.compareValues = global.compareValues || require('../conditions.js').compareValues;
|
||||
|
||||
@@ -15,15 +15,25 @@
|
||||
//
|
||||
// Best-effort partout : un LRS injoignable/mal configuré ne doit JAMAIS
|
||||
// bloquer ni ralentir la partie du joueur.
|
||||
//
|
||||
// NOSONAR S5332 (toutes occurrences "http://adlnet.gov/..." de ce fichier
|
||||
// + les 2 assertions de __tests__/xapi-client.test.js) : identifiants du
|
||||
// vocabulaire xAPI standard ADL, jamais dérérencés/appelés en réseau (de
|
||||
// simples chaînes comparées/embarquées) — le "http://" fait partie de la
|
||||
// chaîne fixée par la spec, jamais "https://" (changer casserait
|
||||
// l'interopérabilité avec n'importe quel LRS). Le VRAI endpoint réseau
|
||||
// (voir forgeXapiSendStatement plus bas) est toujours `config.endpoint`,
|
||||
// saisi par le créateur (routes/publish/xapi_settings.py), jamais un
|
||||
// littéral de ce fichier.
|
||||
|
||||
// Statut Forge -> verbe xAPI ADL standard, uniquement pour les statuts
|
||||
// qui marquent une VRAIE fin de partie — "non_commence"/"en_cours" ne
|
||||
// génèrent rien ici (bruit inutile pour un suivi volontairement léger,
|
||||
// voir le plan).
|
||||
var FORGE_TO_XAPI_VERB = {
|
||||
termine: { id: "http://adlnet.gov/expapi/verbs/completed" },
|
||||
reussi: { id: "http://adlnet.gov/expapi/verbs/passed" },
|
||||
echoue: { id: "http://adlnet.gov/expapi/verbs/failed" },
|
||||
const FORGE_TO_XAPI_VERB = {
|
||||
termine: { id: 'http://adlnet.gov/expapi/verbs/completed' }, // NOSONAR S5332
|
||||
reussi: { id: 'http://adlnet.gov/expapi/verbs/passed' }, // NOSONAR S5332
|
||||
echoue: { id: 'http://adlnet.gov/expapi/verbs/failed' }, // NOSONAR S5332
|
||||
};
|
||||
|
||||
// Libellé humain par verbe — l'id xAPI (http://adlnet.gov/expapi/verbs/...)
|
||||
@@ -33,18 +43,18 @@ var FORGE_TO_XAPI_VERB = {
|
||||
// français ?" — un LRS qui préfère le français (ou n'importe quelle
|
||||
// langue ici) affichera la bonne entrée au lieu de rester bloqué sur
|
||||
// l'anglais.
|
||||
var FORGE_XAPI_VERB_DISPLAY = {
|
||||
"http://adlnet.gov/expapi/verbs/initialized": { "en-US": "initialized", "fr-FR": "initialisé" },
|
||||
"http://adlnet.gov/expapi/verbs/terminated": { "en-US": "terminated", "fr-FR": "quitté" },
|
||||
"http://adlnet.gov/expapi/verbs/completed": { "en-US": "completed", "fr-FR": "terminé" },
|
||||
"http://adlnet.gov/expapi/verbs/passed": { "en-US": "passed", "fr-FR": "réussi" },
|
||||
"http://adlnet.gov/expapi/verbs/failed": { "en-US": "failed", "fr-FR": "échoué" },
|
||||
"http://adlnet.gov/expapi/verbs/scored": { "en-US": "scored", "fr-FR": "noté" },
|
||||
"http://adlnet.gov/expapi/verbs/attempted": { "en-US": "attempted", "fr-FR": "commencé" },
|
||||
"http://adlnet.gov/expapi/verbs/answered": { "en-US": "answered", "fr-FR": "répondu" },
|
||||
const FORGE_XAPI_VERB_DISPLAY = {
|
||||
'http://adlnet.gov/expapi/verbs/initialized': { 'en-US': 'initialized', 'fr-FR': 'initialisé' }, // NOSONAR S5332
|
||||
'http://adlnet.gov/expapi/verbs/terminated': { 'en-US': 'terminated', 'fr-FR': 'quitté' }, // NOSONAR S5332
|
||||
'http://adlnet.gov/expapi/verbs/completed': { 'en-US': 'completed', 'fr-FR': 'terminé' }, // NOSONAR S5332
|
||||
'http://adlnet.gov/expapi/verbs/passed': { 'en-US': 'passed', 'fr-FR': 'réussi' }, // NOSONAR S5332
|
||||
'http://adlnet.gov/expapi/verbs/failed': { 'en-US': 'failed', 'fr-FR': 'échoué' }, // NOSONAR S5332
|
||||
'http://adlnet.gov/expapi/verbs/scored': { 'en-US': 'scored', 'fr-FR': 'noté' }, // NOSONAR S5332
|
||||
'http://adlnet.gov/expapi/verbs/attempted': { 'en-US': 'attempted', 'fr-FR': 'commencé' }, // NOSONAR S5332
|
||||
'http://adlnet.gov/expapi/verbs/answered': { 'en-US': 'answered', 'fr-FR': 'répondu' }, // NOSONAR S5332
|
||||
};
|
||||
|
||||
var forgeXapiLastStatusSent = null; // évite de renvoyer le même statement si le statut ne change pas réellement
|
||||
let forgeXapiLastStatusSent = null; // évite de renvoyer le même statement si le statut ne change pas réellement
|
||||
|
||||
// Identité de l'apprenant : réutilise l'API SCORM déjà trouvée par
|
||||
// scorm-api.js (window.forgeScormApi, posée sur "load" — voir plus bas,
|
||||
@@ -52,23 +62,23 @@ var forgeXapiLastStatusSent = null; // évite de renvoyer le même statement si
|
||||
// si le paquet tourne dans un LMS ; sinon, un acteur anonyme générique.
|
||||
function forgeXapiActor() {
|
||||
try {
|
||||
if (typeof forgeScormApi !== "undefined" && forgeScormApi) {
|
||||
var name = forgeScormApi.LMSGetValue("cmi.core.student_name");
|
||||
var id = forgeScormApi.LMSGetValue("cmi.core.student_id");
|
||||
if (typeof forgeScormApi !== 'undefined' && forgeScormApi) {
|
||||
const name = forgeScormApi.LMSGetValue('cmi.core.student_name');
|
||||
const id = forgeScormApi.LMSGetValue('cmi.core.student_id');
|
||||
if (id) {
|
||||
return { objectType: "Agent", name: name || id, account: { homePage: "urn:forge-engine", name: id } };
|
||||
return { objectType: 'Agent', name: name || id, account: { homePage: 'urn:forge-engine', name: id } };
|
||||
}
|
||||
}
|
||||
} catch (e) { /* API SCORM présente mais qui répond mal — repli silencieux */ }
|
||||
return { objectType: "Agent", name: "Apprenant", mbox: "mailto:anonymous@forge-engine.local" };
|
||||
return { objectType: 'Agent', name: 'Apprenant', mbox: 'mailto:anonymous@forge-engine.local' };
|
||||
}
|
||||
|
||||
function forgeXapiObject() {
|
||||
var config = window.FORGE_XAPI_CONFIG;
|
||||
const config = window.FORGE_XAPI_CONFIG;
|
||||
return {
|
||||
objectType: "Activity",
|
||||
objectType: 'Activity',
|
||||
id: config.activity_id,
|
||||
definition: { name: { "fr-FR": config.activity_name } },
|
||||
definition: { name: { 'fr-FR': config.activity_name } },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -80,24 +90,24 @@ function forgeXapiObject() {
|
||||
// de scène qui a déclenché ce dialogue (voir "ℹ️ Informations") — plus de
|
||||
// "quête" pour le porter.
|
||||
function forgeXapiDialogueObject(dialogueId, objectName) {
|
||||
var config = window.FORGE_XAPI_CONFIG;
|
||||
const config = window.FORGE_XAPI_CONFIG;
|
||||
return {
|
||||
objectType: "Activity",
|
||||
id: config.activity_id + ":dialogue:" + dialogueId,
|
||||
definition: { name: { "fr-FR": objectName || "Dialogue" }, type: "http://adlnet.gov/expapi/activities/simulation" },
|
||||
objectType: 'Activity',
|
||||
id: `${config.activity_id}:dialogue:${dialogueId}`,
|
||||
definition: { name: { 'fr-FR': objectName || 'Dialogue' }, type: 'http://adlnet.gov/expapi/activities/simulation' }, // NOSONAR S5332
|
||||
};
|
||||
}
|
||||
|
||||
function forgeXapiQuestionObject(dialogueId, objectName, questionIndex, questionText) {
|
||||
var config = window.FORGE_XAPI_CONFIG;
|
||||
const config = window.FORGE_XAPI_CONFIG;
|
||||
return {
|
||||
objectType: "Activity",
|
||||
id: config.activity_id + ":dialogue:" + dialogueId + ":question:" + questionIndex,
|
||||
objectType: 'Activity',
|
||||
id: `${config.activity_id}:dialogue:${dialogueId}:question:${questionIndex}`,
|
||||
definition: {
|
||||
name: { "fr-FR": "Question " + (questionIndex + 1) + " — " + (objectName || "Dialogue") },
|
||||
description: { "fr-FR": questionText || "" },
|
||||
type: "http://adlnet.gov/expapi/activities/cmi.interaction",
|
||||
interactionType: "choice",
|
||||
name: { 'fr-FR': `Question ${questionIndex + 1} — ${objectName || 'Dialogue'}` },
|
||||
description: { 'fr-FR': questionText || '' },
|
||||
type: 'http://adlnet.gov/expapi/activities/cmi.interaction', // NOSONAR S5332
|
||||
interactionType: 'choice',
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -110,27 +120,27 @@ function forgeXapiQuestionObject(dialogueId, objectName, questionIndex, question
|
||||
// (forgeXapiObject), ou une sous-activité quête/question
|
||||
// (forgeXapiQuestObject/forgeXapiQuestionObject) pour un suivi plus fin.
|
||||
function forgeXapiSendStatement(verbId, result, object) {
|
||||
var config = window.FORGE_XAPI_CONFIG;
|
||||
const config = window.FORGE_XAPI_CONFIG;
|
||||
if (!config || !config.endpoint) return;
|
||||
var statement = {
|
||||
const statement = {
|
||||
actor: forgeXapiActor(),
|
||||
verb: { id: verbId, display: FORGE_XAPI_VERB_DISPLAY[verbId] || { "en-US": verbId } },
|
||||
verb: { id: verbId, display: FORGE_XAPI_VERB_DISPLAY[verbId] || { 'en-US': verbId } },
|
||||
object: object || forgeXapiObject(),
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
if (result) statement.result = result;
|
||||
var endpoint = config.endpoint.replace(/\/+$/, "") + "/statements";
|
||||
const endpoint = `${config.endpoint.replace(/\/+$/, '')}/statements`;
|
||||
try {
|
||||
fetch(endpoint, {
|
||||
method: "POST",
|
||||
method: 'POST',
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Experience-API-Version": "1.0.3",
|
||||
"Authorization": "Basic " + btoa(config.login + ":" + config.password),
|
||||
'Content-Type': 'application/json',
|
||||
'X-Experience-API-Version': '1.0.3',
|
||||
Authorization: `Basic ${btoa(`${config.login}:${config.password}`)}`,
|
||||
},
|
||||
body: JSON.stringify(statement),
|
||||
}).catch(function (err) {
|
||||
console.warn("xAPI: envoi du statement échoué (LRS injoignable ?)", err);
|
||||
}).catch((err) => {
|
||||
console.warn('xAPI: envoi du statement échoué (LRS injoignable ?)', err);
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn("xAPI: impossible d'envoyer le statement", err);
|
||||
@@ -143,8 +153,8 @@ function forgeXapiSendStatement(verbId, result, object) {
|
||||
function forgeXapiNotifyScoreChanged(newScore) {
|
||||
if (!window.FORGE_XAPI_CONFIG) return;
|
||||
forgeXapiSendStatement(
|
||||
"http://adlnet.gov/expapi/verbs/scored",
|
||||
{ score: { raw: newScore } }
|
||||
'http://adlnet.gov/expapi/verbs/scored', // NOSONAR S5332
|
||||
{ score: { raw: newScore } },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -156,44 +166,51 @@ function forgeXapiNotifyScoreChanged(newScore) {
|
||||
function forgeXapiNotifyDialogueCompleted(dialogueId, objectName) {
|
||||
if (!window.FORGE_XAPI_CONFIG) return;
|
||||
forgeXapiSendStatement(
|
||||
"http://adlnet.gov/expapi/verbs/completed",
|
||||
{ completion: true }, forgeXapiDialogueObject(dialogueId, objectName)
|
||||
'http://adlnet.gov/expapi/verbs/completed', // NOSONAR S5332
|
||||
{ completion: true },
|
||||
forgeXapiDialogueObject(dialogueId, objectName),
|
||||
);
|
||||
}
|
||||
|
||||
function forgeXapiNotifyQuestionAnswered(dialogueId, objectName, questionIndex, questionText, correct, scoreAwarded) {
|
||||
if (!window.FORGE_XAPI_CONFIG) return;
|
||||
forgeXapiSendStatement(
|
||||
"http://adlnet.gov/expapi/verbs/answered",
|
||||
'http://adlnet.gov/expapi/verbs/answered', // NOSONAR S5332
|
||||
{ success: correct, score: { raw: scoreAwarded || 0 } },
|
||||
forgeXapiQuestionObject(dialogueId, objectName, questionIndex, questionText)
|
||||
forgeXapiQuestionObject(dialogueId, objectName, questionIndex, questionText),
|
||||
);
|
||||
}
|
||||
|
||||
function forgeXapiNotifyStatusChanged(newStatus) {
|
||||
if (!window.FORGE_XAPI_CONFIG) return;
|
||||
var verb = FORGE_TO_XAPI_VERB[newStatus];
|
||||
const verb = FORGE_TO_XAPI_VERB[newStatus];
|
||||
if (!verb || newStatus === forgeXapiLastStatusSent) return;
|
||||
forgeXapiLastStatusSent = newStatus;
|
||||
forgeXapiSendStatement(verb.id, {
|
||||
completion: true,
|
||||
success: newStatus !== "echoue",
|
||||
success: newStatus !== 'echoue',
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("load", function () {
|
||||
if (window.FORGE_XAPI_CONFIG) forgeXapiSendStatement("http://adlnet.gov/expapi/verbs/initialized");
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('load', () => {
|
||||
if (window.FORGE_XAPI_CONFIG) forgeXapiSendStatement('http://adlnet.gov/expapi/verbs/initialized'); // NOSONAR S5332
|
||||
});
|
||||
window.addEventListener("beforeunload", function () {
|
||||
if (window.FORGE_XAPI_CONFIG) forgeXapiSendStatement("http://adlnet.gov/expapi/verbs/terminated");
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (window.FORGE_XAPI_CONFIG) forgeXapiSendStatement('http://adlnet.gov/expapi/verbs/terminated'); // NOSONAR S5332
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = {
|
||||
forgeXapiSendStatement, forgeXapiNotifyScoreChanged, forgeXapiNotifyStatusChanged, FORGE_TO_XAPI_VERB,
|
||||
forgeXapiNotifyDialogueCompleted, forgeXapiNotifyQuestionAnswered,
|
||||
forgeXapiDialogueObject, forgeXapiQuestionObject, FORGE_XAPI_VERB_DISPLAY,
|
||||
forgeXapiSendStatement,
|
||||
forgeXapiNotifyScoreChanged,
|
||||
forgeXapiNotifyStatusChanged,
|
||||
FORGE_TO_XAPI_VERB,
|
||||
forgeXapiNotifyDialogueCompleted,
|
||||
forgeXapiNotifyQuestionAnswered,
|
||||
forgeXapiDialogueObject,
|
||||
forgeXapiQuestionObject,
|
||||
FORGE_XAPI_VERB_DISPLAY,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user