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:
william
2026-09-15 16:06:15 +02:00
co-authored by Claude Sonnet 5
parent 7db4803b93
commit c57420c8c9
454 changed files with 16448 additions and 6967 deletions
+149 -105
View File
@@ -41,8 +41,8 @@
// Silencieux (aucun effet) si le widget requis n'est pas posé sur
// l'écran — même esprit que le reste du moteur.
var forgeDialogueBoxState = {};
var forgeQuizBoxState = {};
const forgeDialogueBoxState = {};
const forgeQuizBoxState = {};
window.forgeQuizScore = window.forgeQuizScore || 0;
// Compte TOUTE réponse (bonne ou mauvaise) au fil de la partie — sert
// uniquement à calculer un taux de réussite global (voir
@@ -56,17 +56,18 @@ window.forgeCompletedDialogueIds = window.forgeCompletedDialogueIds || {};
function forgeEscapeHtml(str) {
return String(str == null ? '' : str)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ---------- Boîte de dialogue ----------
function forgeDialogueBoxWidgetOnCurrentScreen() {
return document.querySelector('.playScreen[data-screen-id="' + window.currentScreenId + '"] .dialogueBoxWidget');
return document.querySelector(`.playScreen[data-screen-id="${window.currentScreenId}"] .dialogueBoxWidget`);
}
function forgeDialogueBoxWidgetById(objectId) {
return document.querySelector('.dialogueBoxWidget[data-object-id="' + objectId + '"]');
return document.querySelector(`.dialogueBoxWidget[data-object-id="${objectId}"]`);
}
function forgeDialogueBoxFooterEl(widget) {
@@ -78,7 +79,7 @@ function forgeDialogueBoxFooterEl(widget) {
// que la bulle s'affiche, arrêtée dès qu'une AUTRE ligne (avec ou sans
// sa propre voix off) prend sa place, pour ne jamais superposer deux
// voix off en même temps si le joueur avance vite dans la conversation.
var forgeDialogueLineAudio = null;
let forgeDialogueLineAudio = null;
function forgeStopDialogueLineAudio() {
if (forgeDialogueLineAudio) {
@@ -88,14 +89,16 @@ function forgeStopDialogueLineAudio() {
}
function forgeRenderDialogueBoxLine(widget, line) {
var header = widget.querySelector('[data-dialogue-role="header"]');
var body = widget.querySelector('[data-dialogue-role="body"]');
const header = widget.querySelector('[data-dialogue-role="header"]');
const body = widget.querySelector('[data-dialogue-role="body"]');
if (header) header.textContent = line.speaker;
if (body) body.textContent = line.text;
forgeStopDialogueLineAudio();
if (line.audio_url) {
forgeDialogueLineAudio = new Audio(line.audio_url);
forgeDialogueLineAudio.play().catch(function () {}); // lecture bloquée par le navigateur (pas d'interaction récente) : la bulle reste lisible sans voix off plutôt que de planter.
// lecture bloquée par le navigateur (pas d'interaction récente) : la
// bulle reste lisible sans voix off plutôt que de planter.
forgeDialogueLineAudio.play().catch(() => {});
}
}
@@ -103,24 +106,24 @@ function forgeRenderDialogueBoxLine(widget, line) {
// "Suivant →", voir render_scene_object.py) — appelé avant CHAQUE ligne
// affichée (voir forgeShowCurrentConversationLine).
function forgeResetDialogueBoxFooter(widget, objectId) {
var footer = forgeDialogueBoxFooterEl(widget);
if (footer) footer.innerHTML = '<button type="button" class="dialogueBoxNextBtn" data-dialogue-role="next-btn" onclick="forgeDialogueBoxAdvance(' + objectId + ')">Suivant →</button>';
const footer = forgeDialogueBoxFooterEl(widget);
if (footer) footer.innerHTML = `<button type="button" class="dialogueBoxNextBtn" data-dialogue-role="next-btn" onclick="forgeDialogueBoxAdvance(${objectId})">Suivant →</button>`;
}
// ---------- Boîte à quiz ----------
function forgeQuizBoxWidgetOnCurrentScreen() {
var scoped = document.querySelector('.playScreen[data-screen-id="' + window.currentScreenId + '"] .quizBoxWidget');
const scoped = document.querySelector(`.playScreen[data-screen-id="${window.currentScreenId}"] .quizBoxWidget`);
if (scoped) return scoped;
// Un quiz PLEIN ÉCRAN a été déplacé hors de ".playScreen" (voir
// forgeEscapeCameraForFullscreen ci-dessous) : ne s'y trouve donc plus
// une fois la première question affichée — retrouvé via l'attribut
// posé au déplacement plutôt que par sa position dans le DOM.
return document.querySelector('.quizBoxWidget[data-owner-screen-id="' + window.currentScreenId + '"]');
return document.querySelector(`.quizBoxWidget[data-owner-screen-id="${window.currentScreenId}"]`);
}
function forgeQuizBoxWidgetById(objectId) {
return document.querySelector('.quizBoxWidget[data-object-id="' + objectId + '"]');
return document.querySelector(`.quizBoxWidget[data-object-id="${objectId}"]`);
}
// Bug signalé : "aucune différence, la taille de la fenêtre de jeu est
@@ -137,7 +140,7 @@ function forgeQuizBoxWidgetById(objectId) {
// dessus de le retrouver malgré le déplacement.
function forgeEscapeCameraForFullscreen(widget) {
if (widget.parentNode === document.body) return;
var screenEl = widget.closest('.playScreen');
const screenEl = widget.closest('.playScreen');
if (screenEl) widget.setAttribute('data-owner-screen-id', screenEl.getAttribute('data-screen-id'));
widget.classList.add('quizBoxWidget--detachedForFullscreen');
document.body.appendChild(widget);
@@ -149,10 +152,10 @@ function forgeEscapeCameraForFullscreen(widget) {
// n'est jamais laissé actif par erreur, quel que soit le chemin de
// sortie (voir forgeShowCurrentConversationLine/forgeAdvanceConversation).
function forgeHideQuizBoxOnCurrentScreen() {
var widget = forgeQuizBoxWidgetOnCurrentScreen();
const widget = forgeQuizBoxWidgetOnCurrentScreen();
if (!widget) return;
widget.classList.remove('is-active', 'is-fullscreen');
var objectId = widget.getAttribute('data-object-id');
const objectId = widget.getAttribute('data-object-id');
forgeStopQuizTimer(objectId);
}
@@ -164,10 +167,10 @@ function forgeHideQuizBoxOnCurrentScreen() {
// quiz_box_templates.py), lus ici plutôt que par un nouveau canal de
// payload séparé — cohérent avec le reste de ce moteur (data-* posés au
// rendu, jamais un aller-retour serveur pour du pur affichage).
var forgeQuizTimerState = {};
const forgeQuizTimerState = {};
function forgeStopQuizTimer(objectId) {
var t = forgeQuizTimerState[objectId];
const t = forgeQuizTimerState[objectId];
if (t && t.intervalId) clearInterval(t.intervalId);
delete forgeQuizTimerState[objectId];
}
@@ -178,18 +181,19 @@ function forgeStopQuizTimer(objectId) {
// d'une question à l'autre sans se réinitialiser.
function forgeStartQuizTimer(objectId, widget, seconds, onExpire) {
forgeStopQuizTimer(objectId);
var state = { remaining: seconds };
const state = { remaining: seconds };
forgeQuizTimerState[objectId] = state;
var timerEl = widget.querySelector('[data-quiz-role="timer"]');
const timerEl = widget.querySelector('[data-quiz-role="timer"]');
function render() {
if (!timerEl) return;
var remaining = Math.max(0, state.remaining);
var m = Math.floor(remaining / 60), s = remaining % 60;
timerEl.textContent = '⏱ ' + m + ':' + (s < 10 ? '0' : '') + s;
const remaining = Math.max(0, state.remaining);
const m = Math.floor(remaining / 60); const
s = remaining % 60;
timerEl.textContent = `⏱ ${m}:${s < 10 ? '0' : ''}${s}`;
timerEl.classList.toggle('is-urgent', remaining <= 5);
}
render();
state.intervalId = setInterval(function () {
state.intervalId = setInterval(() => {
state.remaining -= 1;
render();
if (state.remaining <= 0) {
@@ -202,8 +206,8 @@ function forgeStartQuizTimer(objectId, widget, seconds, onExpire) {
// ---------- Score ----------
function forgeUpdateAllScoreWidgets() {
var values = document.querySelectorAll('.scoreWidget [data-score-role="value"]');
for (var i = 0; i < values.length; i++) values[i].textContent = window.forgeQuizScore;
const values = document.querySelectorAll('.scoreWidget [data-score-role="value"]');
for (let i = 0; i < values.length; i++) values[i].textContent = window.forgeQuizScore;
forgeUpdateQuizBoxScoreDisplays();
}
@@ -213,8 +217,8 @@ function forgeUpdateAllScoreWidgets() {
// .quizBoxTopBar, static/style.css), donc sans effet visuel pour un quiz
// non-plein-écran, mais tenu à jour dans tous les cas (coûte rien).
function forgeUpdateQuizBoxScoreDisplays() {
var values = document.querySelectorAll('.quizBoxWidget [data-quiz-role="score"]');
for (var i = 0; i < values.length; i++) values[i].textContent = 'Score : ' + window.forgeQuizScore;
const values = document.querySelectorAll('.quizBoxWidget [data-quiz-role="score"]');
for (let i = 0; i < values.length; i++) values[i].textContent = `Score : ${window.forgeQuizScore}`;
}
// Alimente aussi gameData.scoring (voir static/js/play/offline/
@@ -237,7 +241,7 @@ function forgeSyncQuizScoreToScorm() {
// (aucune action de flow "Modifier un score" ici), donc jamais notifié
// sans cet appel direct — même bug que le commentaire ci-dessus pour
// SCORM.
if (typeof forgeXapiNotifyScoreChanged === "function") forgeXapiNotifyScoreChanged(gameData.scoring.score);
if (typeof forgeXapiNotifyScoreChanged === 'function') forgeXapiNotifyScoreChanged(gameData.scoring.score);
}
// Un dialogue affiché = la partie a commencé, même sans le moindre point
@@ -264,21 +268,21 @@ function forgeSyncDialogueStartedToScorm() {
// partie. Aucune question posée du tout (jeu fait uniquement de
// dialogues) : reste "termine" (ni réussi ni échoué, rien à évaluer).
function forgeComputeFinalScormStatus() {
var total = window.forgeQuizTotalAnswered || 0;
const total = window.forgeQuizTotalAnswered || 0;
if (total === 0) return 'termine';
var percent = (window.forgeQuizCorrectAnswered / total) * 100;
var threshold = typeof window.FORGE_SUCCESS_THRESHOLD_PERCENT === 'number' ? window.FORGE_SUCCESS_THRESHOLD_PERCENT : 70;
const percent = (window.forgeQuizCorrectAnswered / total) * 100;
const threshold = typeof window.FORGE_SUCCESS_THRESHOLD_PERCENT === 'number' ? window.FORGE_SUCCESS_THRESHOLD_PERCENT : 70;
return percent >= threshold ? 'reussi' : 'echoue';
}
function forgeSyncAllDialoguesCompletionToScorm() {
var completableIds = (gameData.completable_dialogue_ids || []);
var allDone = completableIds.length > 0 && completableIds.every(function (id) { return !!window.forgeCompletedDialogueIds[id]; });
const completableIds = (gameData.completable_dialogue_ids || []);
const allDone = completableIds.length > 0 && completableIds.every((id) => !!window.forgeCompletedDialogueIds[id]);
if (!allDone) return;
gameData.scoring = gameData.scoring || { score: 0, status: 'non_commence' };
var finalStatus = forgeComputeFinalScormStatus();
const finalStatus = forgeComputeFinalScormStatus();
gameData.scoring.status = finalStatus;
if (typeof forgeXapiNotifyStatusChanged === "function") forgeXapiNotifyStatusChanged(finalStatus);
if (typeof forgeXapiNotifyStatusChanged === 'function') forgeXapiNotifyStatusChanged(finalStatus);
}
function forgeAddQuizScore(amount) {
@@ -295,10 +299,10 @@ function forgeAddQuizScore(amount) {
// forgeShowQuizBox ci-dessous, qui avance seule si aucune boîte à quiz
// n'est posée : impossible de bloquer la conversation indéfiniment).
function forgeShowCurrentConversationLine(dialogueObjectId) {
var state = forgeDialogueBoxState[dialogueObjectId];
const state = forgeDialogueBoxState[dialogueObjectId];
if (!state) return;
var line = state.lines[state.index];
var widget = forgeDialogueBoxWidgetById(dialogueObjectId);
const line = state.lines[state.index];
const widget = forgeDialogueBoxWidgetById(dialogueObjectId);
if (line.type === 'question') {
if (widget) widget.classList.remove('is-active');
forgeShowQuizBox(dialogueObjectId, state.dialogueId, state.objectName, line, state.index);
@@ -336,8 +340,8 @@ function forgeShowDialogueBox(dialogueId, objectName, lines, markCompleted, then
// sans boîte de dialogue posée reste ignorée plus loin (voir
// forgeShowCurrentConversationLine), même logique symétrique qu'une
// question sans boîte à quiz posée (voir forgeShowQuizBox).
var widget = forgeDialogueBoxWidgetOnCurrentScreen();
var stateKey = widget ? widget.getAttribute('data-object-id') : ('_sansBoiteDialogue_' + dialogueId);
const widget = forgeDialogueBoxWidgetOnCurrentScreen();
const stateKey = widget ? widget.getAttribute('data-object-id') : (`_sansBoiteDialogue_${dialogueId}`);
forgeSyncDialogueStartedToScorm();
if (!lines.length) {
// Rien à jouer : applique quand même markCompleted PUIS thenAction
@@ -347,8 +351,12 @@ function forgeShowDialogueBox(dialogueId, objectName, lines, markCompleted, then
return;
}
forgeDialogueBoxState[stateKey] = {
dialogueId: dialogueId, objectName: objectName, lines: lines, index: 0,
markCompleted: !!markCompleted, thenAction: thenAction,
dialogueId,
objectName,
lines,
index: 0,
markCompleted: !!markCompleted,
thenAction,
};
forgeShowCurrentConversationLine(stateKey);
}
@@ -358,7 +366,7 @@ function forgeShowDialogueBox(dialogueId, objectName, lines, markCompleted, then
// comme terminé" de l'assistant) et propage vers le SCORM/xAPI.
function forgeCompleteDialogue(dialogueId, objectName) {
window.forgeCompletedDialogueIds[dialogueId] = true;
if (typeof forgeXapiNotifyDialogueCompleted === "function") forgeXapiNotifyDialogueCompleted(dialogueId, objectName);
if (typeof forgeXapiNotifyDialogueCompleted === 'function') forgeXapiNotifyDialogueCompleted(dialogueId, objectName);
forgeSyncAllDialoguesCompletionToScorm();
}
@@ -370,13 +378,13 @@ function forgeCompleteDialogue(dialogueId, objectName) {
// déclencheur pour lui ajouter des actions") — MÊME point de sortie "ce
// dialogue est fini", jamais avant.
function forgeAdvanceConversation(dialogueObjectId) {
var state = forgeDialogueBoxState[dialogueObjectId];
const state = forgeDialogueBoxState[dialogueObjectId];
if (!state) return;
state.index += 1;
if (state.index >= state.lines.length) {
forgeStopDialogueLineAudio();
forgeHideQuizBoxOnCurrentScreen();
var widget = forgeDialogueBoxWidgetById(dialogueObjectId);
const widget = forgeDialogueBoxWidgetById(dialogueObjectId);
if (state.markCompleted) forgeCompleteDialogue(state.dialogueId, state.objectName);
if (state.thenAction) forgeRunCollisionRuleAction(state.thenAction, null, { name: state.objectName });
if (widget) widget.classList.remove('is-active');
@@ -391,7 +399,7 @@ function forgeAdvanceConversation(dialogueObjectId) {
// effet pendant une question en cours (le "Suivant" n'existe pas alors,
// voir forgeShowCurrentConversationLine, mais gardé strict par sécurité).
function forgeDialogueBoxAdvance(objectId) {
var state = forgeDialogueBoxState[objectId];
const state = forgeDialogueBoxState[objectId];
if (!state || (state.lines[state.index] && state.lines[state.index].type === 'question')) return;
forgeAdvanceConversation(objectId);
}
@@ -403,13 +411,19 @@ function forgeDialogueBoxAdvance(objectId) {
// plutôt que de bloquer la conversation pour toujours en attendant une
// réponse qu'aucun widget ne peut recueillir.
function forgeShowQuizBox(dialogueObjectId, dialogueId, objectName, line, questionIndex) {
var widget = forgeQuizBoxWidgetOnCurrentScreen();
const widget = forgeQuizBoxWidgetOnCurrentScreen();
if (!widget) { forgeAdvanceConversation(dialogueObjectId); return; }
var quizObjectId = widget.getAttribute('data-object-id');
var previousState = forgeQuizBoxState[quizObjectId];
const quizObjectId = widget.getAttribute('data-object-id');
const previousState = forgeQuizBoxState[quizObjectId];
forgeQuizBoxState[quizObjectId] = {
dialogueObjectId: dialogueObjectId, correctIndex: line.correct_index, rewardAmount: line.reward_amount, resolved: false,
dialogueId: dialogueId, objectName: objectName, questionIndex: questionIndex, questionText: line.text,
dialogueObjectId,
correctIndex: line.correct_index,
rewardAmount: line.reward_amount,
resolved: false,
dialogueId,
objectName,
questionIndex,
questionText: line.text,
// Minuteur "pour tout le quiz" (voir forgeStartQuizTimer plus bas) :
// ne démarre qu'à la PREMIÈRE question, continue de tourner ensuite
// sans se réinitialiser — ce drapeau survit d'une question à l'autre
@@ -423,40 +437,38 @@ function forgeShowQuizBox(dialogueObjectId, dialogueId, objectName, line, questi
correctCount: previousState ? previousState.correctCount : 0,
answeredCount: previousState ? previousState.answeredCount : 0,
};
var header = widget.querySelector('[data-quiz-role="header"]');
var questionEl = widget.querySelector('[data-quiz-role="question"]');
var choicesEl = widget.querySelector('[data-quiz-role="choices"]');
const header = widget.querySelector('[data-quiz-role="header"]');
const questionEl = widget.querySelector('[data-quiz-role="question"]');
const choicesEl = widget.querySelector('[data-quiz-role="choices"]');
if (header) header.textContent = objectName || '';
if (questionEl) questionEl.textContent = line.text;
if (choicesEl) {
choicesEl.innerHTML = line.choices.map(function (choice, i) {
return '<button type="button" class="quizBoxChoiceBtn" onclick="forgeQuizBoxAnswer(' + quizObjectId + ', ' + i + ')">' + forgeEscapeHtml(choice) + '</button>';
}).join('');
choicesEl.innerHTML = line.choices.map((choice, i) => `<button type="button" class="quizBoxChoiceBtn" onclick="forgeQuizBoxAnswer(${quizObjectId}, ${i})">${forgeEscapeHtml(choice)}</button>`).join('');
// "Continuer →" TOUJOURS présent dès la question (demande explicite :
// "le bouton continuer est toujours visible mais activé seulement
// quand un choix est fait", comme la maquette manga validée) —
// désactivé jusqu'à ce que forgeQuizBoxAnswer le réactive (jamais
// recréé à ce moment-là, juste .disabled=false, voir plus bas).
choicesEl.innerHTML += '<button type="button" class="quizBoxContinueBtn" onclick="forgeQuizBoxContinue(' + quizObjectId + ')" disabled>Continuer →</button>';
choicesEl.innerHTML += `<button type="button" class="quizBoxContinueBtn" onclick="forgeQuizBoxContinue(${quizObjectId})" disabled>Continuer →</button>`;
}
// "Plein écran" (demande explicite) — reposé à CHAQUE question,
// sans effet si déjà présent (voir .quizBoxWidget.is-fullscreen,
// static/style.css). Échappe d'abord au conteneur de scène transformé
// (voir forgeEscapeCameraForFullscreen) — sinon position:fixed reste
// borné à sa taille "caméra" au lieu du vrai écran du joueur.
var isFullscreen = widget.getAttribute('data-fullscreen') === '1';
const isFullscreen = widget.getAttribute('data-fullscreen') === '1';
if (isFullscreen) forgeEscapeCameraForFullscreen(widget);
widget.classList.toggle('is-fullscreen', isFullscreen);
forgeUpdateQuizBoxScoreDisplays();
// Minuteur (optionnel — "aucun" reste le réglage par défaut, jamais
// imposé, voir screens/rendering/quiz_box_config.py).
var timerMode = widget.getAttribute('data-timer-mode');
var timerSeconds = parseInt(widget.getAttribute('data-timer-seconds'), 10) || 0;
const timerMode = widget.getAttribute('data-timer-mode');
const timerSeconds = parseInt(widget.getAttribute('data-timer-seconds'), 10) || 0;
if (timerMode === 'question') {
forgeStartQuizTimer(quizObjectId, widget, timerSeconds, function () { forgeQuizBoxTimeout(quizObjectId, false); });
forgeStartQuizTimer(quizObjectId, widget, timerSeconds, () => { forgeQuizBoxTimeout(quizObjectId, false); });
} else if (timerMode === 'quiz' && !forgeQuizBoxState[quizObjectId].quizTimerStarted) {
forgeQuizBoxState[quizObjectId].quizTimerStarted = true;
forgeStartQuizTimer(quizObjectId, widget, timerSeconds, function () { forgeQuizBoxTimeout(quizObjectId, true); });
forgeStartQuizTimer(quizObjectId, widget, timerSeconds, () => { forgeQuizBoxTimeout(quizObjectId, true); });
} else if (timerMode !== 'quiz') {
forgeStopQuizTimer(quizObjectId);
}
@@ -469,7 +481,7 @@ function forgeShowQuizBox(dialogueObjectId, dialogueId, objectName, line, questi
widget.classList.add('is-active');
// Effet d'entrée propre au modèle (voir static/js/play/
// quiz-box-templates.js) — optionnel, "defaut" n'en a aucun.
var showFx = forgeQuizTemplateEffectsFor(widget).onShow;
const showFx = forgeQuizTemplateEffectsFor(widget).onShow;
if (showFx) showFx(widget);
}
@@ -477,7 +489,7 @@ function forgeShowQuizBox(dialogueObjectId, dialogueId, objectName, line, questi
// templates.js, chargé AVANT ce fichier) — absent en environnement de
// test qui ne charge pas ce script, d'où la garde `typeof`.
function forgeQuizTemplateEffectsFor(widget) {
var template = (widget && widget.getAttribute('data-quiz-template')) || 'defaut';
const template = (widget && widget.getAttribute('data-quiz-template')) || 'defaut';
return (typeof forgeQuizTemplateEffects !== 'undefined' && forgeQuizTemplateEffects[template]) || {};
}
@@ -491,11 +503,11 @@ function forgeQuizTemplateEffectsFor(widget) {
// en rouge s'il était faux) le temps de la voir avant d'avancer. Un
// second clic pendant cet affichage est ignoré (`resolved`).
function forgeQuizBoxAnswer(quizObjectId, choiceIndex) {
var state = forgeQuizBoxState[quizObjectId];
const state = forgeQuizBoxState[quizObjectId];
if (!state || state.resolved) return;
state.resolved = true;
var widget = forgeQuizBoxWidgetById(quizObjectId);
var correct = choiceIndex === state.correctIndex;
const widget = forgeQuizBoxWidgetById(quizObjectId);
const correct = choiceIndex === state.correctIndex;
// Minuteur "par question" : cette question est résolue (bonne réponse,
// mauvaise réponse, OU minuteur expiré via forgeQuizBoxTimeout, qui
// appelle CETTE fonction avec choiceIndex=-1) — jamais le minuteur
@@ -503,8 +515,8 @@ function forgeQuizBoxAnswer(quizObjectId, choiceIndex) {
// d'une question à l'autre.
if (widget && widget.getAttribute('data-timer-mode') === 'question') forgeStopQuizTimer(quizObjectId);
if (widget) {
var buttons = widget.querySelectorAll('.quizBoxChoiceBtn');
for (var i = 0; i < buttons.length; i++) {
const buttons = widget.querySelectorAll('.quizBoxChoiceBtn');
for (let i = 0; i < buttons.length; i++) {
buttons[i].disabled = true;
if (i === state.correctIndex) buttons[i].classList.add('is-correct');
else if (i === choiceIndex) buttons[i].classList.add('is-wrong');
@@ -512,7 +524,7 @@ function forgeQuizBoxAnswer(quizObjectId, choiceIndex) {
}
window.forgeQuizTotalAnswered += 1;
state.answeredCount += 1;
var fx = forgeQuizTemplateEffectsFor(widget);
const fx = forgeQuizTemplateEffectsFor(widget);
if (correct) {
window.forgeQuizCorrectAnswered += 1;
state.correctCount += 1;
@@ -521,13 +533,28 @@ function forgeQuizBoxAnswer(quizObjectId, choiceIndex) {
} else if (fx.onWrong) {
fx.onWrong(widget);
}
if (typeof forgeXapiNotifyQuestionAnswered === "function") {
forgeXapiNotifyQuestionAnswered(state.dialogueId, state.objectName, state.questionIndex, state.questionText, correct, correct ? state.rewardAmount : 0);
if (typeof forgeXapiNotifyQuestionAnswered === 'function') {
forgeXapiNotifyQuestionAnswered(
state.dialogueId,
state.objectName,
state.questionIndex,
state.questionText,
correct,
correct ? state.rewardAmount : 0,
);
}
// cmi.interactions.n.* (SCORM 2004 uniquement, voir scorm2004-api.js) :
// no-op en SCORM 1.2/hors LMS (forgeScorm2004Api y reste null).
if (typeof forgeScorm2004NotifyQuestionAnswered === "function") {
forgeScorm2004NotifyQuestionAnswered(state.dialogueId, state.objectName, state.questionIndex, state.questionText, correct, choiceIndex, state.correctIndex);
if (typeof forgeScorm2004NotifyQuestionAnswered === 'function') {
forgeScorm2004NotifyQuestionAnswered(
state.dialogueId,
state.objectName,
state.questionIndex,
state.questionText,
correct,
choiceIndex,
state.correctIndex,
);
}
// RGAA/WCAG 2.2.1 (délai réglable) : avancait auparavant tout seul
// après un délai fixe (700ms/1400ms), jamais assez pour un lecteur
@@ -539,7 +566,7 @@ function forgeQuizBoxAnswer(quizObjectId, choiceIndex) {
// effacerait le surlignage vert/rouge qu'on vient de poser sur les
// choix juste au-dessus).
if (widget) {
var continueBtnToEnable = widget.querySelector('.quizBoxContinueBtn');
const continueBtnToEnable = widget.querySelector('.quizBoxContinueBtn');
if (continueBtnToEnable) continueBtnToEnable.disabled = false;
}
}
@@ -558,18 +585,18 @@ function forgeQuizBoxAnswer(quizObjectId, choiceIndex) {
// les lignes.
function forgeQuizPrepareResultScreen(quizObjectId, widget, state, forceEnd) {
if (!widget) return null;
var dialogueState = forgeDialogueBoxState[state.dialogueObjectId];
const dialogueState = forgeDialogueBoxState[state.dialogueObjectId];
if (!dialogueState) return null;
var isEnding = forceEnd || (dialogueState.index + 1 >= dialogueState.lines.length);
const isEnding = forceEnd || (dialogueState.index + 1 >= dialogueState.lines.length);
if (!isEnding) return null;
var completeFx = forgeQuizTemplateEffectsFor(widget).onQuizComplete;
const completeFx = forgeQuizTemplateEffectsFor(widget).onQuizComplete;
if (!completeFx) return null;
var correctCount = state.correctCount;
var answeredCount = state.answeredCount;
const { correctCount } = state;
const { answeredCount } = state;
return function () {
widget.classList.add('is-active');
if (widget.getAttribute('data-fullscreen') === '1') widget.classList.add('is-fullscreen');
completeFx(widget, { correctCount: correctCount, totalCount: answeredCount });
completeFx(widget, { correctCount, totalCount: answeredCount });
};
}
@@ -593,10 +620,10 @@ function forgeQuizPrepareResultScreen(quizObjectId, widget, state, forceEnd) {
// ci-dessus (widget déjà masqué) est donc le résultat visible le plus
// courant en pratique.
function forgeQuizResultQuit(quizObjectId) {
var widget = forgeQuizBoxWidgetById(quizObjectId);
const widget = forgeQuizBoxWidgetById(quizObjectId);
if (widget) {
widget.classList.remove('is-active', 'is-fullscreen', 'is-showing-result');
var content = widget.querySelector('.classiqueContent');
const content = widget.querySelector('.classiqueContent');
if (content) content.classList.remove('is-showing-result');
}
if (typeof window !== 'undefined' && typeof window.close === 'function') window.close();
@@ -608,10 +635,10 @@ function forgeQuizResultQuit(quizObjectId) {
// résultat, voir forgeQuizPrepareResultScreen ci-dessus), jamais
// automatique (voir le commentaire ci-dessus).
function forgeQuizBoxContinue(quizObjectId) {
var state = forgeQuizBoxState[quizObjectId];
const state = forgeQuizBoxState[quizObjectId];
if (!state) return;
var widget = forgeQuizBoxWidgetById(quizObjectId);
var showResultScreen = forgeQuizPrepareResultScreen(quizObjectId, widget, state, false);
const widget = forgeQuizBoxWidgetById(quizObjectId);
const showResultScreen = forgeQuizPrepareResultScreen(quizObjectId, widget, state, false);
if (widget && !showResultScreen) widget.classList.remove('is-active');
delete forgeQuizBoxState[quizObjectId];
forgeAdvanceConversation(state.dialogueObjectId);
@@ -629,31 +656,31 @@ function forgeQuizBoxContinue(quizObjectId) {
// choiceIndex=-1 (ne correspond à aucun bouton : révèle la bonne réponse
// sans en marquer une "fausse"), jamais de code dupliqué.
function forgeQuizBoxTimeout(quizObjectId, wholeQuizExpired) {
var state = forgeQuizBoxState[quizObjectId];
const state = forgeQuizBoxState[quizObjectId];
if (!state || state.resolved) return;
forgeQuizBoxAnswer(quizObjectId, -1);
if (!wholeQuizExpired) return;
var widget = forgeQuizBoxWidgetById(quizObjectId);
const widget = forgeQuizBoxWidgetById(quizObjectId);
// Pas de "Continuer →" ici : rien à continuer, le temps du quiz ENTIER
// est écoulé — désactivé/relibellé pour éviter toute course avec la
// fin automatique programmée juste en dessous.
if (widget) {
var continueBtn = widget.querySelector('.quizBoxContinueBtn');
const continueBtn = widget.querySelector('.quizBoxContinueBtn');
if (continueBtn) { continueBtn.disabled = true; continueBtn.textContent = 'Temps écoulé…'; }
}
setTimeout(function () {
var s = forgeQuizBoxState[quizObjectId];
setTimeout(() => {
const s = forgeQuizBoxState[quizObjectId];
if (!s) return; // déjà fermé entre-temps
// forceEnd=true : le quiz ENTIER se termine ici, qu'il reste ou non
// des questions (voir forgeQuizPrepareResultScreen) — capturé AVANT
// que forgeAdvanceConversation ne supprime forgeDialogueBoxState.
var showResultScreen = forgeQuizPrepareResultScreen(quizObjectId, widget, s, true);
const showResultScreen = forgeQuizPrepareResultScreen(quizObjectId, widget, s, true);
if (widget && !showResultScreen) widget.classList.remove('is-active', 'is-fullscreen');
delete forgeQuizBoxState[quizObjectId];
// Force l'épuisement des lignes restantes — même sortie que la fin
// naturelle d'un dialogue (markCompleted/thenAction inclus, voir
// forgeAdvanceConversation), sans dupliquer cette logique.
var dialogueState = forgeDialogueBoxState[s.dialogueObjectId];
const dialogueState = forgeDialogueBoxState[s.dialogueObjectId];
if (dialogueState) {
dialogueState.index = dialogueState.lines.length;
forgeAdvanceConversation(s.dialogueObjectId);
@@ -666,17 +693,34 @@ function forgeQuizBoxTimeout(quizObjectId, wholeQuizExpired) {
// contrôleurs de ce dossier.
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
forgeShowDialogueBox, forgeDialogueBoxAdvance, forgeCompleteDialogue,
forgeDialogueBoxWidgetOnCurrentScreen, forgeDialogueBoxWidgetById, forgeRenderDialogueBoxLine,
forgeShowDialogueBox,
forgeDialogueBoxAdvance,
forgeCompleteDialogue,
forgeDialogueBoxWidgetOnCurrentScreen,
forgeDialogueBoxWidgetById,
forgeRenderDialogueBoxLine,
forgeResetDialogueBoxFooter,
forgeShowQuizBox, forgeQuizBoxAnswer, forgeQuizBoxContinue, forgeQuizBoxTimeout,
forgeQuizBoxWidgetOnCurrentScreen, forgeQuizBoxWidgetById, forgeHideQuizBoxOnCurrentScreen,
forgeUpdateAllScoreWidgets, forgeUpdateQuizBoxScoreDisplays, forgeAddQuizScore,
forgeAdvanceConversation, forgeShowCurrentConversationLine,
forgeSyncQuizScoreToScorm, forgeSyncDialogueStartedToScorm, forgeSyncAllDialoguesCompletionToScorm,
forgeShowQuizBox,
forgeQuizBoxAnswer,
forgeQuizBoxContinue,
forgeQuizBoxTimeout,
forgeQuizBoxWidgetOnCurrentScreen,
forgeQuizBoxWidgetById,
forgeHideQuizBoxOnCurrentScreen,
forgeUpdateAllScoreWidgets,
forgeUpdateQuizBoxScoreDisplays,
forgeAddQuizScore,
forgeAdvanceConversation,
forgeShowCurrentConversationLine,
forgeSyncQuizScoreToScorm,
forgeSyncDialogueStartedToScorm,
forgeSyncAllDialoguesCompletionToScorm,
forgeComputeFinalScormStatus,
forgeStartQuizTimer, forgeStopQuizTimer, forgeQuizTimerState,
forgeStartQuizTimer,
forgeStopQuizTimer,
forgeQuizTimerState,
forgeEscapeCameraForFullscreen,
forgeQuizPrepareResultScreen, forgeQuizResultQuit,
forgeQuizPrepareResultScreen,
forgeQuizResultQuit,
};
}