Lot 4 modernisation JS (SonarLint) + restauration CI sonarqube non-bloquante
Build and deploy / test-python (push) Successful in 11m18s
Build and deploy / test-js (push) Successful in 1m16s
Build and deploy / lint-python (push) Successful in 4m24s
Build and deploy / lint-js (push) Successful in 2m51s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Successful in 5m30s

Corrige les findings SonarQube (via SonarLint IDE, fichier par fichier)
sur ~24 fichiers static/js/ : parseFloat/parseInt -> Number.*,
.replace(/x/g,y) -> .replaceAll, .indexOf() -> .includes()/.startsWith(),
getAttribute/setAttribute -> .dataset, tableaux -> Set, x && x.y -> x?.y
(verifie site par site), extraction de template litteraux imbriques,
ternaires imbriquees, refactors de complexite cognitive (S3776) via
tables de dispatch, Object.hasOwn, .at(), et deduplication de fonctions
identiques (S4144). Deux exceptions S2486 documentees/corrigees
(filter-repeater-rows.js) et un cas S2703 de partage inter-scripts
complete (_collisionWizard, trigger-editor.js <-> collision-rules-editor.js).
Details complets dans CODE_QUALITY.md section 5.

Restaure aussi le job CI "sonarqube" (non-bloquant) dans
.gitea/workflows/deploy.yml maintenant que l'instance prod est
operationnelle.

Suites vertes : 276/276 JS (node --test), 591/591 Python (pytest).

SKIP=djlint sur ce commit : hook djlint bloquant sur le backlog H021
(styles inline, 49 occurrences/6 templates) deja documente comme dette
assumee non traitee dans CODE_QUALITY.md section 6, aucun rapport avec
ce commit (aucun template touche ici) - valide explicitement avec
l'utilisateur avant de contourner ce hook.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-09-18 16:38:29 +02:00
co-authored by Claude Sonnet 5
parent 66d8eaeae8
commit ccf836f2c5
36 changed files with 1720 additions and 838 deletions
@@ -111,3 +111,25 @@ test('forgeDecodeClauses — format JSON illimité prioritaire sur le legacy', (
assert.equal(decoded.clauses.length, 1);
assert.equal(decoded.combinator, 'ou');
});
test('forgeDecodeClauses — _filtres_json invalide : avertit en console, retombe sur le legacy sans planter (bug S2486 : exception avalée en silence auparavant)', () => {
const attrs = {
_filtres_json: '{not valid json',
_filtre_champ: 'pv',
_filtre_operateur: 'superieur_egal',
_filtre_valeur: '10',
};
const originalWarn = console.warn;
const warnCalls = [];
console.warn = (...args) => { warnCalls.push(args); };
try {
const decoded = forgeDecodeClauses(attrs, '_filtres_json', '_filtre_combinateur', '_filtre');
assert.equal(warnCalls.length, 1);
assert.match(warnCalls[0][0], /_filtres_json invalide/);
// Repli sur l'ancien format à 2 filtres fixes, comme avant ce fix.
assert.equal(decoded.clauses.length, 1);
assert.equal(decoded.clauses[0].champ, 'pv');
} finally {
console.warn = originalWarn;
}
});
@@ -136,6 +136,121 @@ test('jauge — pourcentage et couleur interpolée depuis un champ d\'objet', ()
assert.ok(html.includes('jaugeBar'));
});
// Couverture des branches d'afficherValeur/fieldNameNom AVANT le refactor
// S3776 (24 -> lot 4 "modernisation JS") — pour garantir que l'extraction
// en sous-fonctions ne change aucun comportement.
test('jauge — _afficher_valeur affiche la valeur brute du champ dans un label superposé', () => {
const el = {
id: 10,
widget: 'jauge',
style: {},
attributes: {
_definition_id: '1', _champ: 'points', _min: '0', _max: '100', _afficher_valeur: '1',
},
};
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
assert.ok(html.includes('>42</div>'));
});
test('jauge — _afficher_valeur sans valeur trouvée (champ inconnu) affiche un tiret cadratin, pas "null"/"undefined"', () => {
const el = {
id: 10,
widget: 'jauge',
style: {},
attributes: {
_definition_id: '1', _champ: 'champ_qui_n_existe_pas', _min: '0', _max: '100', _afficher_valeur: '1',
},
};
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
assert.ok(html.includes('>—</div>'));
});
test('jauge — _row_id sélectionne une ligne précise (pas la plus récente)', () => {
const el = {
id: 10,
widget: 'jauge',
style: {},
attributes: {
_definition_id: '1', _champ: 'points', _row_id: '2', _min: '0', _max: '100',
},
};
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
assert.ok(html.includes('value="7"')); // ligne id=2 (Bob, points=7), pas la plus récente (Alice)
});
test('jauge — _champ_nom affiche un label avec le nom, aligné à gauche seulement en position "dessus"+"gauche"', () => {
const elGauche = {
id: 10,
widget: 'jauge',
style: {},
attributes: {
_definition_id: '1',
_champ: 'points',
_champ_nom: 'nom',
_min: '0',
_max: '100',
_nom_position: 'dessus',
_nom_alignement: 'gauche',
_nom_gras: '1',
_nom_italique: '1',
},
};
const htmlGauche = forgeRenderElementHtml(elGauche, baseGameData(), {}, {});
assert.ok(htmlGauche.includes('text-align:left'));
assert.ok(htmlGauche.includes('font-weight:700'));
assert.ok(htmlGauche.includes('font-style:italic'));
assert.ok(htmlGauche.includes('>Alice</div>'));
const elCote = {
id: 11,
widget: 'jauge',
style: {},
attributes: {
_definition_id: '1', _champ: 'points', _champ_nom: 'nom', _min: '0', _max: '100', _nom_position: 'cote',
},
};
const htmlCote = forgeRenderElementHtml(elCote, baseGameData(), {}, {});
assert.ok(htmlCote.includes('text-align:center'));
assert.ok(htmlCote.includes('flex-direction:row')); // "cote" bascule aussi la disposition générale
});
test('jauge — _champ_nom sans valeur trouvée (ligne/champ vide) : aucun label de nom généré', () => {
const el = {
id: 10,
widget: 'jauge',
style: {},
attributes: {
_definition_id: '1', _champ: 'points', _champ_nom: 'champ_qui_n_existe_pas', _min: '0', _max: '100',
},
};
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
assert.ok(!html.includes('text-align'));
});
test('jauge — classe CSS supplémentaire (_class_*) fusionnée sur la barre', () => {
const el = {
id: 10,
widget: 'jauge',
style: {},
attributes: {
_definition_id: '1', _champ: 'points', _min: '0', _max: '100', _class_extra: 'ma-classe',
},
};
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
assert.match(html, /class="progress jaugeBar ma-classe"/);
});
test('jauge — min/max absents retombent sur 0/100, valeur non numérique retombe sur min', () => {
const el = {
id: 10,
widget: 'jauge',
style: {},
attributes: { _definition_id: '1', _champ: 'nom' }, // "nom" = texte, pas un nombre
};
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
assert.ok(html.includes('value="0"')); // valeur non numérique -> repli sur min (0 par défaut)
});
test('répéteur — une repeaterItem par ligne, {{champ}} substitué', () => {
const el = {
id: 11,
+74 -45
View File
@@ -18,53 +18,82 @@ function forgePyMod(a, b) {
return ((a % b) + b) % b;
}
function computeDefinirNombre(rawValue, round) {
const n = Number.parseFloat(rawValue);
return Number.isNaN(n) ? 0 : round(n);
}
// Partagée entre "incrementer" et "decrementer" (même calcul, signe
// inversé) — extrait de forgeComputeNewValue (lot 4 "modernisation JS", S3776).
function computeIncrementerOuDecrementer(operation, current, rawValue, round) {
let amount = (rawValue === null || rawValue === undefined || rawValue === '') ? 0 : Number.parseFloat(rawValue);
if (Number.isNaN(amount)) amount = 0;
if (operation === 'decrementer') amount = -amount;
return round((current || 0) + amount);
}
function computeMultiplier(current, rawValue, round) {
let factor = (rawValue === null || rawValue === undefined || rawValue === '') ? 1 : Number.parseFloat(rawValue);
if (Number.isNaN(factor)) factor = 1;
return round((current || 0) * factor);
}
// Partagée entre "diviser" et "modulo" — extrait de forgeComputeNewValue.
function computeDiviserOuModulo(operation, current, rawValue, round) {
let divisor = (rawValue === null || rawValue === undefined || rawValue === '') ? 0 : Number.parseFloat(rawValue);
if (Number.isNaN(divisor)) divisor = 0;
if (divisor === 0) return current;
const result = operation === 'diviser' ? (current || 0) / divisor : forgePyMod(current || 0, divisor);
return round(result);
}
// Partagée entre "minimum" et "maximum" — extrait de forgeComputeNewValue.
function computeMinimumOuMaximum(operation, current, rawValue, round) {
const bound = Number.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);
}
function computeAlea(rawValue, isDecimal) {
const parts = String(rawValue || '').split(',');
let lo = Number.parseFloat(parts[0]); let
hi = Number.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
}
// Dispatch par nom d'opération — chaque fonction ci-dessus reçoit déjà
// tout ce dont elle a besoin (current/rawValue/round/isDecimal), ce
// dispatcher n'a donc plus qu'une seule décision (présence d'un
// handler), au lieu d'empiler ici les conditions de chaque branche (même
// méthode que runActionNode, static/js/play/actions.js).
const OPERATION_HANDLERS = {
definir_texte: (current, rawValue) => rawValue || '',
definir_nombre: (current, rawValue, round) => computeDefinirNombre(rawValue, round),
definir_bool_vrai: () => 1,
definir_bool_faux: () => 0,
basculer_bool: (current) => (current ? 0 : 1),
incrementer: (current, rawValue, round) => computeIncrementerOuDecrementer('incrementer', current, rawValue, round),
decrementer: (current, rawValue, round) => computeIncrementerOuDecrementer('decrementer', current, rawValue, round),
multiplier: (current, rawValue, round) => computeMultiplier(current, rawValue, round),
diviser: (current, rawValue, round) => computeDiviserOuModulo('diviser', current, rawValue, round),
modulo: (current, rawValue, round) => computeDiviserOuModulo('modulo', current, rawValue, round),
minimum: (current, rawValue, round) => computeMinimumOuMaximum('minimum', current, rawValue, round),
maximum: (current, rawValue, round) => computeMinimumOuMaximum('maximum', current, rawValue, round),
alea: (current, rawValue, round, isDecimal) => computeAlea(rawValue, isDecimal),
};
function forgeComputeNewValue(operation, current, rawValue, isDecimal) {
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') {
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') {
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') {
let divisor = (rawValue === null || rawValue === undefined || rawValue === '') ? 0 : parseFloat(rawValue);
if (Number.isNaN(divisor)) divisor = 0;
if (divisor === 0) return current;
const result = operation === 'diviser' ? (current || 0) / divisor : forgePyMod(current || 0, divisor);
return round(result);
}
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') {
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}`);
const handler = OPERATION_HANDLERS[operation];
if (!handler) throw new Error(`opération inconnue : ${operation}`);
return handler(current, rawValue, round, isDecimal);
}
// static/js/play/__tests__/ (node:test) — même patron que conditions.js.
+17 -9
View File
@@ -21,7 +21,15 @@ function forgeDecodeClauses(attrs, clausesAttr, combinatorAttr, legacyPrefix) {
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) clauses = parsed;
} catch (e) { clauses = null; }
} catch (e) {
// Signalement devtools uniquement (voir CODE_QUALITY.md, S2486) :
// _filtres_json est produit par le rendu serveur, jamais tapé à la
// main — un JSON invalide ici trahit presque toujours un bug côté
// éditeur/serveur. Comportement inchangé : repli sur l'ancien
// format ou aucun filtre, comme avant.
console.warn('_filtres_json invalide, filtre ignoré', e);
clauses = null;
}
}
if (clauses === null && legacyPrefix) {
clauses = [];
@@ -48,7 +56,7 @@ function forgeResolveVariablePath(rawValue, path) {
let current;
try {
current = rawValue ? JSON.parse(rawValue) : null;
} catch (e) {
} catch (e) { // NOSONAR S2486 - variable pas au format JSON, cas normal (voir commentaire de la fonction)
return null;
}
let m;
@@ -57,9 +65,9 @@ function forgeResolveVariablePath(rawValue, path) {
const key = m[1]; const
index = m[2];
try {
current = key !== undefined ? current[key] : current[parseInt(index, 10)];
current = key !== undefined ? current[key] : current[Number.parseInt(index, 10)];
if (current === undefined) return null;
} catch (e) {
} catch (e) { // NOSONAR S2486 - chemin qui ne correspond pas a la forme actuelle de la variable, cas normal
return null;
}
}
@@ -91,7 +99,7 @@ function forgeResolveFilterValue(gameData, rawValue) {
const varMatch = FORGE_VAR_REF_PATTERN.exec(stripped);
if (varMatch) {
const variable = gameData.variables && gameData.variables[varMatch[1].trim()];
const variable = 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]);
}
@@ -103,8 +111,8 @@ function forgeResolveFilterValue(gameData, rawValue) {
const names = gameData.definition_names || {};
for (const defId in names) {
if (names[defId] === objectName) {
const rows = gameData.data && gameData.data[defId];
if (rows && rows.length) {
const rows = gameData.data?.[defId];
if (rows?.length) {
return rows[0][fieldName];
}
return null;
@@ -119,7 +127,7 @@ function forgeClauseMatches(gameData, definitionId, row, clause) {
if (!fieldName) return true;
const operator = clause.operateur || 'egal';
const expected = forgeResolveFilterValue(gameData, clause.valeur || '');
const fieldsMetaForDef = gameData.fields_meta && gameData.fields_meta[definitionId];
const fieldsMetaForDef = 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);
@@ -127,7 +135,7 @@ function forgeClauseMatches(gameData, definitionId, row, clause) {
// Port de filter_rows_by_clauses.
function forgeFilterRowsByClauses(gameData, definitionId, rows, clauses, combinator) {
if (!clauses || !clauses.length) return rows;
if (!clauses?.length) return rows;
if (combinator === 'ou') {
return rows.filter((r) => clauses.some((c) => forgeClauseMatches(gameData, definitionId, r, c)));
}
+17 -17
View File
@@ -15,7 +15,7 @@
// play.html) et sont routés depuis forgeRenderElementHtml() ci-dessous
// via forgeSpecialRenderer().
const FORGE_LAYOUT_CAPABLE_WIDGETS = ['conteneur', 'repeteur_donnees', 'liste', 'liste_numerotee', 'groupe_champs', 'tableau'];
const FORGE_LAYOUT_CAPABLE_WIDGETS = new Set(['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
@@ -66,9 +66,9 @@ const FORGE_SPECIAL_WIDGET_FIXED_ATTRS = { tableau: { class: 'table' } };
function forgeHtmlEscape(s) {
return String(s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#x27;');
.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#x27;');
}
// Port de apply_ctx.py — {{champ}} remplacé par la valeur de la ligne en
@@ -89,7 +89,7 @@ function forgeAutoId(el) {
// Port de parent_flex_direction.py.
function forgeParentFlexDirection(parentEl) {
const style = (parentEl && parentEl.style) || {};
const style = parentEl?.style || {};
if (style.display !== 'flex') return null;
return style['flex-direction'] || 'row';
}
@@ -99,8 +99,8 @@ 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) {
const style = { ...el.style || {} };
if (FORGE_LAYOUT_CAPABLE_WIDGETS.indexOf(widgetKey) !== -1 && !('display' in style)) {
const style = { ...el.style };
if (FORGE_LAYOUT_CAPABLE_WIDGETS.has(widgetKey) && !('display' in style)) {
style.display = 'flex';
if (!('flex-direction' in style)) style['flex-direction'] = 'column';
}
@@ -150,13 +150,13 @@ function forgeVisibleAttrs(el, meta, ctx) {
const rawAttrs = el.attributes || {};
const attrs = {};
Object.keys(rawAttrs).forEach((k) => {
if (k.indexOf('_') === 0) return;
if (k.startsWith('_')) return;
const v = rawAttrs[k];
attrs[k] = typeof v === 'string' ? forgeApplyCtx(v, ctx) : v;
});
Object.assign(attrs, meta.fixedAttrs || {});
const extraClasses = Object.keys(rawAttrs)
.filter((k) => k.indexOf('_class_') === 0 && rawAttrs[k])
.filter((k) => k.startsWith('_class_') && rawAttrs[k])
.map((k) => rawAttrs[k]);
if (extraClasses.length) {
const baseClass = attrs.class || '';
@@ -170,7 +170,7 @@ function forgeVisibleAttrs(el, meta, ctx) {
// Port de render_children.py.
function forgeRenderChildren(el, gameData, childrenMap, ctx) {
const kids = (childrenMap && childrenMap[el.id]) || [];
const kids = childrenMap?.[el.id] || [];
const flexDir = forgeParentFlexDirection(el);
return kids.map((c) => forgeRenderElementHtml(c, gameData, childrenMap, ctx, flexDir)).join('');
}
@@ -182,9 +182,9 @@ function forgeResolveBoundRowCtx(gameData, attrs) {
let definitionId = attrs._data_definition_id;
if (!definitionId) return null;
definitionId = String(definitionId);
const fieldsMetaForDef = gameData.fields_meta && gameData.fields_meta[definitionId];
const fieldsMetaForDef = gameData.fields_meta?.[definitionId];
if (!fieldsMetaForDef) return null;
let rows = (gameData.data && gameData.data[definitionId]) || [];
let rows = 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;
@@ -206,8 +206,8 @@ 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) {
const templateTree = gameData.element_type_templates && gameData.element_type_templates[String(el.element_type_id)];
if (!templateTree || !templateTree.top.length) return [null, false];
const templateTree = gameData.element_type_templates?.[String(el.element_type_id)];
if (!templateTree?.top.length) return [null, false];
const isOverlayOnly = templateTree.top.every((c) => c.widget === 'superposition');
let { top } = templateTree;
if (isOverlayOnly) {
@@ -255,7 +255,7 @@ function forgeRenderElementHtml(el, gameData, childrenMap, ctx, parentFlexDirect
const attrsRaw = el.attributes || {};
const hasVisibilityCondition = attrsRaw._visibilite_cond_mode === 'variable' || attrsRaw._visibilite_cond_mode === 'objet';
const playMode = !!((ctx || {})._forge_play_mode);
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>`;
}
@@ -268,7 +268,7 @@ function forgeRenderElementHtml(el, gameData, childrenMap, ctx, parentFlexDirect
};
const boundCtx = forgeResolveBoundRowCtx(gameData, attrsRaw);
if (boundCtx) ctx = { ...ctx || {}, ...boundCtx };
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"),
@@ -319,7 +319,7 @@ function forgeRenderElementHtml(el, gameData, childrenMap, ctx, parentFlexDirect
return mark(`<${tag} ${forgeAttrString(attrs, style)}>${inner}</${tag}>`);
}
const safeContent = content ? forgeHtmlEscape(content).replace(/\n/g, '<br>') : '';
const safeContent = content ? forgeHtmlEscape(content).replaceAll('\n', '<br>') : '';
return mark(`<${tag} ${forgeAttrString(attrs, style)}>${safeContent}${childHtml}</${tag}>`);
}
+60 -29
View File
@@ -6,15 +6,25 @@
function forgeHexToRgb(hexColor) {
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)];
return [
Number.parseInt(hex.substr(0, 2), 16),
Number.parseInt(hex.substr(2, 2), 16),
Number.parseInt(hex.substr(4, 2), 16),
];
}
// Extrait de forgeInterpolateColor (lot 4 "modernisation JS", S7721) :
// fonction pure (aucune fermeture sur lo/hi/mixed/ratio), pas besoin
// d'être redéfinie à chaque appel.
function forgeByteToHex(n) {
return n.toString(16).padStart(2, '0');
}
function forgeInterpolateColor(lowHex, highHex, ratio) {
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('')}`;
return `#${mixed.map(forgeByteToHex).join('')}`;
}
// Port de _read_field_value — repli sur la ligne la PLUS RÉCENTE si
@@ -23,14 +33,42 @@ function forgeInterpolateColor(lowHex, highHex, ratio) {
function forgeReadFieldValue(gameData, definitionId, rowId, fieldName) {
if (!definitionId || !fieldName) return null;
definitionId = String(definitionId);
const rows = gameData.data && gameData.data[definitionId];
if (!rows || !rows.length) return null;
const rows = gameData.data?.[definitionId];
if (!rows?.length) return null;
let row = null;
if (rowId) row = rows.find((r) => String(r.id) === String(rowId));
if (!row) row = rows[0];
return row[fieldName];
}
// Label superposé qui affiche la valeur BRUTE du champ (indépendant du
// pourcentage/couleur) — extrait de forgeRenderJauge (lot 4 "modernisation
// JS", S3776).
function forgeJaugeValueLabelHtml(afficherValeur, rawValue) {
if (!afficherValeur) return '';
return '<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>`;
}
// Label du NOM (ex. "Alice"), lu depuis un second champ optionnel — extrait
// de forgeRenderJauge.
function forgeJaugeNameLabelHtml(gameData, definitionId, rowId, fieldNameNom, nomPosition, nomAlignement, nomStyle) {
if (!fieldNameNom) return '';
const nomValue = forgeReadFieldValue(gameData, definitionId, rowId, fieldNameNom);
if (nomValue === null || nomValue === undefined || nomValue === '') return '';
const textAlign = (nomPosition === 'dessus' && nomAlignement === 'gauche') ? 'left' : 'center';
const {
nomPolice, nomTaille, nomGras, nomItalique,
} = nomStyle;
return `<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>`;
}
function forgeRenderJauge(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
const attrs = el.attributes || {};
const nomPosition = attrs._nom_position || 'dessus';
@@ -47,14 +85,14 @@ function forgeRenderJauge(el, meta, gameData, childrenMap, ctx, parentFlexDirect
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;
let minV = Number.parseFloat(attrs._min); if (Number.isNaN(minV)) minV = 0;
let maxV = Number.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';
const rawValue = fieldName ? forgeReadFieldValue(gameData, definitionId, rowId, fieldName) : null;
let value = parseFloat(rawValue);
let value = Number.parseFloat(rawValue);
if (Number.isNaN(value)) value = minV;
const span = (maxV - minV) || 1;
@@ -62,29 +100,22 @@ function forgeRenderJauge(el, meta, gameData, childrenMap, ctx, parentFlexDirect
const percent = Math.round(ratio * 1000) / 10;
const fillColor = forgeInterpolateColor(couleurBasse, couleurHaute, ratio);
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>`;
}
let nameHtml = '';
if (fieldNameNom) {
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>`;
}
}
const labelHtml = forgeJaugeValueLabelHtml(afficherValeur, rawValue);
const nomStyle = {
nomPolice, nomTaille, nomGras, nomItalique,
};
const nameHtml = forgeJaugeNameLabelHtml(
gameData,
definitionId,
rowId,
fieldNameNom,
nomPosition,
nomAlignement,
nomStyle,
);
const extraClasses = Object.keys(attrs)
.filter((k) => k.indexOf('_class_') === 0 && attrs[k])
.filter((k) => k.startsWith('_class_') && attrs[k])
.map((k) => attrs[k]).join(' ');
const barClass = forgeHtmlEscape((`progress jaugeBar ${extraClasses}`).trim());
const bar = `<progress class="${barClass}" value="${percent}" max="100" `
+1 -1
View File
@@ -4,7 +4,7 @@
// 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) {
const isPlayMode = !!(ctx && ctx._forge_play_mode);
const isPlayMode = !!ctx?._forge_play_mode;
const attrsRaw = el.attributes || {};
const boxColor = attrsRaw._couleur_boite || '#1f2430';
const radius = attrsRaw._arrondi || '12';
+4 -4
View File
@@ -10,14 +10,14 @@ function forgeRenderRepeater(el, meta, gameData, childrenMap, ctx, parentFlexDir
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]) || [];
const children = 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>`;
}
definitionId = String(definitionId);
const fieldsMetaForDef = gameData.fields_meta && gameData.fields_meta[definitionId];
const fieldsMetaForDef = 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>`;
@@ -28,11 +28,11 @@ function forgeRenderRepeater(el, meta, gameData, childrenMap, ctx, parentFlexDir
let templateTree = null;
if (templateTypeId) {
templateTree = gameData.element_type_templates && gameData.element_type_templates[String(templateTypeId)];
templateTree = gameData.element_type_templates?.[String(templateTypeId)];
}
const items = rows.map((row) => {
const rowCtx = { ...ctx || {} };
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"
@@ -48,7 +48,7 @@ function forgeRenderFieldset(el, meta, gameData, childrenMap, ctx, parentFlexDir
function forgeRenderTable(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
const attrs = forgeVisibleAttrs(el, meta, ctx);
const headerRow = ['1', 'true', 'on'].indexOf((el.attributes || {})._header_row) !== -1;
const headerRow = ['1', 'true', 'on'].includes(el.attributes?._header_row);
const content = forgeApplyCtx(el.content || '', ctx);
const style = forgeStyleString(el, parentFlexDirection, el.widget);
const rows = content.split('\n').filter((r) => r.trim());
@@ -83,7 +83,7 @@ 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.
const iconUrl = (gameData.icon_data_uris && gameData.icon_data_uris[iconSlug]) || (`icons/${iconSlug}.svg`);
const iconUrl = 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
@@ -94,7 +94,7 @@ function forgeRenderIcone(el, meta, gameData, childrenMap, ctx, parentFlexDirect
}
function forgeRenderOnglets(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
const tabs = (childrenMap && childrenMap[el.id]) || [];
const tabs = childrenMap?.[el.id] || [];
const attrs = forgeVisibleAttrs(el, meta, ctx);
const tabsExtraClasses = attrs.class || '';
delete attrs.class;
@@ -138,9 +138,9 @@ function forgeRenderOnglets(el, meta, gameData, childrenMap, ctx, parentFlexDire
function forgeRenderPersonnage(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
const attrs = forgeVisibleAttrs(el, meta, ctx);
attrs['data-personnage'] = '1';
const animations = gameData.personnage_animations && gameData.personnage_animations[String(el.id)];
const 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] : '';
const idleFrame = 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.