Enrichit le reporting SCORM/xAPI et met en conformité RGAA le player
Build and deploy / test-python (push) Successful in 5m56s
Build and deploy / test-js (push) Successful in 51s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped

SCORM/xAPI :
- Ajoute l'export SCORM 2004 (3rd/4th edition) au choix, en plus du 1.2
  par défaut : sépare completion_status/success_status (un échec reste
  "completed" au lieu de retomber à tort en "incomplete" comme force la
  1.2), remonte aussi cmi.interactions.n.* par question répondue.
- Fournit cmi.core.score.min/max (1.2 et 2004), calculé depuis les
  récompenses de quiz du jeu, pour que le LMS affiche un vrai pourcentage
  au lieu du score brut à tort étiqueté "%".
- Libellés de verbes xAPI en français en plus de l'anglais.

Accessibilité (RGAA/WCAG 2.1 AA) sur le player :
- Navigation clavier des objets de scène "au clic"/"au survol"
  (tabindex, role, Entrée/Espace, focus/blur).
- alt sur les images (nom auteur ou décoratif), aria-hidden sur les
  icônes seules, role="dialog"/aria-live sur les boîtes de dialogue/quiz.
- Landmark <main> + titre de page, respect de prefers-reduced-motion.
- Le quiz n'avance plus automatiquement après un délai fixe : bouton
  "Continuer →" explicite (RGAA 2.2.1).
- Avertissement de contraste dans l'éditeur de style de dialogue.
- Déclaration d'accessibilité téléchargeable depuis la modale d'export.
This commit is contained in:
william
2026-09-06 00:17:51 +02:00
parent f7a50e5afe
commit c504ace167
36 changed files with 1581 additions and 79 deletions
@@ -11,8 +11,9 @@ global.gameData = {};
const {
forgeQuestById, forgeShowDialogueBox, forgeDialogueBoxAdvance,
forgeDialogueBoxAcceptQuest, forgeDialogueBoxDeclineQuest,
forgeQuizBoxAnswer, forgeUpdateAllScoreWidgets,
forgeQuizBoxAnswer, forgeQuizBoxContinue, forgeUpdateAllScoreWidgets,
forgeAddQuizScore, forgeSyncAllQuestsCompletionToScorm,
forgeComputeFinalScormStatus,
} = require('../dialogue-box-controller.js');
function fakeWidget(objectId) {
@@ -37,6 +38,11 @@ function fakeWidget(objectId) {
test.beforeEach(() => {
global.window.currentScreenId = 1;
// Compteurs de réussite (voir forgeComputeFinalScormStatus) : remis à
// zéro entre chaque test, sinon les réponses au quiz d'un test
// fuiraient sur le calcul de statut final du test suivant.
global.window.forgeQuizTotalAnswered = 0;
global.window.forgeQuizCorrectAnswered = 0;
global.gameData = {
quests: [
{ id: 5, title: 'La quête du sorcier', status: 'nouvelle', objectif: 'Parler au sorcier', dialogues: { nouvelle: [{ speaker: 'Le Sorcier', text: 'Bonjour !' }, { speaker: 'Joueur', text: 'Salut.' }], en_cours: [], terminee: [] } },
@@ -236,12 +242,10 @@ test('une ligne "question" est affichée dans la boîte à QUIZ (header = quête
} finally { global.document = previousDoc; }
});
test('mauvaise réponse au quiz : aucun point, MAIS avance quand même (ne bloque jamais la progression), la bonne réponse est surlignée', () => {
test('mauvaise réponse au quiz : aucun point, MAIS avance quand même sur "Continuer →" (ne bloque jamais la progression), la bonne réponse est surlignée', () => {
const dialogueWidget = fakeWidget(42);
const quizWidget = fakeQuizWidget(99, 2);
const previousDoc = global.document;
const previousTimeout = global.setTimeout;
global.setTimeout = fn => fn(); // exécute la révélation immédiatement pour le test
global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget);
try {
forgeShowDialogueBox(10);
@@ -250,20 +254,21 @@ test('mauvaise réponse au quiz : aucun point, MAIS avance quand même (ne bloqu
assert.equal(window.forgeQuizScore, 0); // aucun point
assert.equal(quizWidget._buttons[0]._classes.has('is-wrong'), true); // le choix cliqué (faux) surligné en rouge
assert.equal(quizWidget._buttons[1]._classes.has('is-correct'), true); // la BONNE réponse indiquée quand même
// Avance quand même à la ligne suivante — jamais bloqué sur une mauvaise réponse.
// RGAA/WCAG 2.2.1 : plus d'avance automatique — jamais bloqué sur une
// mauvaise réponse, mais seulement au clic explicite sur "Continuer →".
assert.match(quizWidget._roles.choices.innerHTML, /forgeQuizBoxContinue\(99\)/);
assert.equal(quizWidget._classes.has('is-active'), true); // toujours affichée tant que non cliqué
forgeQuizBoxContinue(99);
assert.equal(quizWidget._classes.has('is-active'), false);
assert.equal(dialogueWidget._classes.has('is-active'), true);
assert.equal(dialogueWidget._roles.body.textContent, 'Bien joué !');
} finally { global.document = previousDoc; global.setTimeout = previousTimeout; }
} finally { global.document = previousDoc; }
});
test('un second clic pendant la révélation (avant l\'avance automatique) est ignoré', () => {
test('un second clic pendant la révélation (avant "Continuer →") est ignoré', () => {
const dialogueWidget = fakeWidget(42);
const quizWidget = fakeQuizWidget(99, 2);
const previousDoc = global.document;
const previousTimeout = global.setTimeout;
const pending = [];
global.setTimeout = fn => pending.push(fn); // ne s'exécute PAS tout de suite -> la question reste affichée
global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget);
try {
forgeShowDialogueBox(10);
@@ -271,17 +276,15 @@ test('un second clic pendant la révélation (avant l\'avance automatique) est i
forgeQuizBoxAnswer(99, 0); // 1er clic (faux)
forgeQuizBoxAnswer(99, 1); // 2nd clic pendant la révélation -> ignoré
assert.equal(window.forgeQuizScore, 0); // le 2nd clic (bon) n'a pas compté
pending.forEach(fn => fn());
assert.equal(window.forgeQuizScore, 0); // toujours 0 après l'avance différée
} finally { global.document = previousDoc; global.setTimeout = previousTimeout; }
forgeQuizBoxContinue(99);
assert.equal(window.forgeQuizScore, 0); // toujours 0 après avoir continué
} finally { global.document = previousDoc; }
});
test('bonne réponse au quiz : crédite le score, la surligne en vert, masque la boîte, avance à la ligne suivante', () => {
test('bonne réponse au quiz : crédite le score, la surligne en vert, "Continuer →" masque la boîte et avance à la ligne suivante', () => {
const dialogueWidget = fakeWidget(42);
const quizWidget = fakeQuizWidget(99, 2);
const previousDoc = global.document;
const previousTimeout = global.setTimeout;
global.setTimeout = fn => fn();
global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget);
try {
forgeShowDialogueBox(10);
@@ -289,6 +292,8 @@ test('bonne réponse au quiz : crédite le score, la surligne en vert, masque la
forgeQuizBoxAnswer(99, 1); // bonne réponse
assert.equal(window.forgeQuizScore, 5);
assert.equal(quizWidget._buttons[1]._classes.has('is-correct'), true);
assert.equal(quizWidget._classes.has('is-active'), true); // pas encore avancé
forgeQuizBoxContinue(99);
assert.equal(quizWidget._classes.has('is-active'), false);
assert.equal(dialogueWidget._classes.has('is-active'), true);
assert.equal(dialogueWidget._roles.body.textContent, 'Bien joué !');
@@ -296,7 +301,11 @@ test('bonne réponse au quiz : crédite le score, la surligne en vert, masque la
forgeDialogueBoxAdvance(42); // dernière ligne -> dialogue "en_cours" épuisé -> quête "terminee"
assert.equal(forgeQuestById(10).status, 'terminee');
assert.equal(dialogueWidget._classes.has('is-active'), false);
} finally { global.document = previousDoc; global.setTimeout = previousTimeout; }
} finally { global.document = previousDoc; }
});
test('forgeQuizBoxContinue ne fait rien si déjà appelé/aucune question en attente (état déjà nettoyé)', () => {
assert.doesNotThrow(() => forgeQuizBoxContinue(99999));
});
test('aucune boîte à quiz posée sur la scène : la question est ignorée, la conversation avance seule', () => {
@@ -394,3 +403,118 @@ test('la dernière ligne d\'un dialogue "en_cours" propage la complétion à gam
assert.equal(gameData.scoring.status, 'termine');
} finally { global.document = previousDoc; }
});
// ---------- Seuil de réussite (voir db/games/get_success_threshold.py) ----------
test('forgeComputeFinalScormStatus — "termine" si aucune question posée (rien à évaluer)', () => {
global.window.forgeQuizTotalAnswered = 0;
global.window.forgeQuizCorrectAnswered = 0;
assert.equal(forgeComputeFinalScormStatus(), 'termine');
});
test('forgeComputeFinalScormStatus — "reussi" si le taux de bonnes réponses atteint le seuil configuré', () => {
global.window.forgeQuizTotalAnswered = 10;
global.window.forgeQuizCorrectAnswered = 7;
global.window.FORGE_SUCCESS_THRESHOLD_PERCENT = 70;
assert.equal(forgeComputeFinalScormStatus(), 'reussi');
});
test('forgeComputeFinalScormStatus — "echoue" si le taux de bonnes réponses est sous le seuil configuré', () => {
global.window.forgeQuizTotalAnswered = 10;
global.window.forgeQuizCorrectAnswered = 6;
global.window.FORGE_SUCCESS_THRESHOLD_PERCENT = 70;
assert.equal(forgeComputeFinalScormStatus(), 'echoue');
});
test('forgeComputeFinalScormStatus — seuil par défaut (70%) si FORGE_SUCCESS_THRESHOLD_PERCENT absent (hors export SCORM)', () => {
global.window.forgeQuizTotalAnswered = 10;
global.window.forgeQuizCorrectAnswered = 8;
delete global.window.FORGE_SUCCESS_THRESHOLD_PERCENT;
assert.equal(forgeComputeFinalScormStatus(), 'reussi');
});
test('forgeSyncAllQuestsCompletionToScorm propage reussi/echoue (pas juste "termine") une fois toutes les quêtes finies', () => {
global.gameData.quests = [{ id: 1, status: 'terminee' }];
global.gameData.scoring = { score: 0, status: 'en_cours' };
global.window.forgeQuizTotalAnswered = 4;
global.window.forgeQuizCorrectAnswered = 1; // 25% < seuil par défaut (70%)
delete global.window.FORGE_SUCCESS_THRESHOLD_PERCENT;
forgeSyncAllQuestsCompletionToScorm();
assert.equal(gameData.scoring.status, 'echoue');
});
// ---------- Granularité xAPI (quête acceptée/terminée, question répondue) ----------
test('accepter une quête notifie xAPI (verbe "attempted" ciblant CETTE quête)', () => {
const widget = fakeWidget(42);
global.gameData.scoring = undefined;
const previousDoc = global.document;
global.document = { querySelector: () => widget };
const calls = [];
global.forgeXapiNotifyQuestAccepted = quest => calls.push(quest);
try {
forgeDialogueBoxAcceptQuest(5, 42);
assert.equal(calls.length, 1);
assert.equal(calls[0].id, 5);
} finally { global.document = previousDoc; delete global.forgeXapiNotifyQuestAccepted; }
});
test('répondre à une question notifie xAPI avec la bonne/mauvaise réponse et le score gagné', () => {
const dialogueWidget = fakeWidget(42);
const quizWidget = {
getAttribute: attr => (attr === 'data-object-id' ? '99' : null),
classList: { add: () => {}, remove: () => {} },
querySelector: sel => (sel.indexOf('choices') !== -1 ? { innerHTML: '' } : { textContent: '' }),
querySelectorAll: () => [],
};
global.gameData.quests = [
{ id: 5, title: 'La quête du sorcier', status: 'en_cours', dialogues: { nouvelle: [], en_cours: [{ type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 5 }], terminee: [] } },
];
const previousDoc = global.document;
global.document = {
querySelector: sel => (sel.indexOf('quizBoxWidget') !== -1 ? quizWidget : dialogueWidget),
querySelectorAll: () => [],
};
const calls = [];
global.forgeXapiNotifyQuestionAnswered = (quest, index, text, correct, score) => calls.push({ quest, index, text, correct, score });
try {
forgeShowDialogueBox(5);
forgeQuizBoxAnswer(99, 0); // bonne réponse (correct_index=0)
assert.equal(calls.length, 1);
assert.equal(calls[0].quest.id, 5);
assert.equal(calls[0].correct, true);
assert.equal(calls[0].score, 5);
forgeQuizBoxContinue(99); // clic explicite (RGAA 2.2.1, plus d'avance automatique)
} finally { global.document = previousDoc; delete global.forgeXapiNotifyQuestionAnswered; }
});
test('répondre à une question notifie AUSSI le SCORM 2004 (cmi.interactions.n.*, indépendant de xAPI)', () => {
const dialogueWidget = fakeWidget(42);
const quizWidget = {
getAttribute: attr => (attr === 'data-object-id' ? '99' : null),
classList: { add: () => {}, remove: () => {} },
querySelector: sel => (sel.indexOf('choices') !== -1 ? { innerHTML: '' } : { textContent: '' }),
querySelectorAll: () => [],
};
global.gameData.quests = [
{ id: 5, title: 'La quête du sorcier', status: 'en_cours', dialogues: { nouvelle: [], en_cours: [{ type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 5 }], terminee: [] } },
];
const previousDoc = global.document;
global.document = {
querySelector: sel => (sel.indexOf('quizBoxWidget') !== -1 ? quizWidget : dialogueWidget),
querySelectorAll: () => [],
};
const calls = [];
global.forgeScorm2004NotifyQuestionAnswered = (quest, index, text, correct, choiceIndex, correctIndex) =>
calls.push({ quest, index, text, correct, choiceIndex, correctIndex });
try {
forgeShowDialogueBox(5);
forgeQuizBoxAnswer(99, 1); // mauvaise réponse (correct_index=0, répond 1)
assert.equal(calls.length, 1);
assert.equal(calls[0].quest.id, 5);
assert.equal(calls[0].correct, false);
assert.equal(calls[0].choiceIndex, 1);
assert.equal(calls[0].correctIndex, 0);
forgeQuizBoxContinue(99); // clic explicite (RGAA 2.2.1, plus d'avance automatique)
} finally { global.document = previousDoc; delete global.forgeScorm2004NotifyQuestionAnswered; }
});
+70 -20
View File
@@ -40,6 +40,12 @@
var forgeDialogueBoxState = {};
var 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
// forgeSyncAllQuestsCompletionToScorm), distinct de window.forgeQuizScore
// qui ne compte que les points gagnés.
window.forgeQuizTotalAnswered = window.forgeQuizTotalAnswered || 0;
window.forgeQuizCorrectAnswered = window.forgeQuizCorrectAnswered || 0;
function forgeQuestById(questId) {
return (gameData.quests || []).find(function (q) { return q.id === questId; });
@@ -135,12 +141,30 @@ function forgeSyncQuestStartedToScorm() {
// Le jeu entier est considéré terminé quand TOUTES les quêtes connues
// sont "terminee" — règle simple et prévisible plutôt que de deviner
// laquelle des quêtes créées "compte" pour la fin du jeu.
// Réussi/Échoué (voir db/games/get_success_threshold.py,
// window.FORGE_SUCCESS_THRESHOLD_PERCENT injecté par publish/
// build_scorm_package.py) : demande utilisateur — "il manque
// l'indicateur succès/échec sur le SCORM". Calculé sur le taux de
// bonnes réponses au quiz (forgeQuizCorrectAnswered/forgeQuizTotalAnswered)
// SUR TOUTE LA PARTIE, pas quête par quête — cohérent avec "toutes les
// quêtes terminées" qui est déjà la seule condition de fin de 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;
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;
return percent >= threshold ? 'reussi' : 'echoue';
}
function forgeSyncAllQuestsCompletionToScorm() {
var quests = gameData.quests || [];
if (!quests.length || !quests.every(function (q) { return q.status === 'terminee'; })) return;
gameData.scoring = gameData.scoring || { score: 0, status: 'non_commence' };
gameData.scoring.status = 'termine';
if (typeof forgeXapiNotifyStatusChanged === "function") forgeXapiNotifyStatusChanged('termine');
var finalStatus = forgeComputeFinalScormStatus();
gameData.scoring.status = finalStatus;
if (typeof forgeXapiNotifyStatusChanged === "function") forgeXapiNotifyStatusChanged(finalStatus);
}
function forgeAddQuizScore(amount) {
@@ -163,7 +187,7 @@ function forgeShowCurrentConversationLine(dialogueObjectId) {
var widget = forgeDialogueBoxWidgetById(dialogueObjectId);
if (line.type === 'question') {
if (widget) widget.classList.remove('is-active');
forgeShowQuizBox(dialogueObjectId, forgeQuestById(state.questId), line);
forgeShowQuizBox(dialogueObjectId, forgeQuestById(state.questId), line, state.index);
return;
}
forgeHideQuizBoxOnCurrentScreen();
@@ -215,6 +239,7 @@ function forgeAdvanceConversation(dialogueObjectId) {
// questions la quête passe au statut terminé".
if (quest && quest.status === 'en_cours') {
quest.status = 'terminee';
if (typeof forgeXapiNotifyQuestCompleted === "function") forgeXapiNotifyQuestCompleted(quest);
forgeSyncAllQuestsCompletionToScorm();
}
if (widget) widget.classList.remove('is-active');
@@ -234,26 +259,19 @@ function forgeDialogueBoxAdvance(objectId) {
forgeAdvanceConversation(objectId);
}
// Combien de temps la bonne/mauvaise réponse reste affichée (surlignée,
// voir forgeQuizBoxAnswer) avant d'avancer automatiquement à la ligne
// suivante — assez long pour LIRE la bonne réponse en cas d'erreur, plus
// court si la réponse était déjà correcte (voir static/style.css,
// @keyframes quizPopIn/quizWrongShake pour l'animation elle-même).
var FORGE_QUIZ_REVEAL_DELAY_CORRECT = 700;
var FORGE_QUIZ_REVEAL_DELAY_WRONG = 1400;
// Affiche une question ("❓ Question") dans la boîte à quiz posée sur
// l'écran — header = "Quête : <titre>", corps = la question + ses choix
// (voir demande utilisateur) — si l'auteur n'a posé aucune boîte à quiz,
// la question est ignorée (avance directement) plutôt que de bloquer la
// conversation pour toujours en attendant une réponse qu'aucun widget ne
// peut recueillir.
function forgeShowQuizBox(dialogueObjectId, quest, line) {
function forgeShowQuizBox(dialogueObjectId, quest, line, questionIndex) {
var widget = forgeQuizBoxWidgetOnCurrentScreen();
if (!widget) { forgeAdvanceConversation(dialogueObjectId); return; }
var quizObjectId = widget.getAttribute('data-object-id');
forgeQuizBoxState[quizObjectId] = {
dialogueObjectId: dialogueObjectId, correctIndex: line.correct_index, rewardAmount: line.reward_amount, resolved: false,
quest: quest, questionIndex: questionIndex, questionText: line.text,
};
var header = widget.querySelector('[data-quiz-role="header"]');
var questionEl = widget.querySelector('[data-quiz-role="question"]');
@@ -297,13 +315,43 @@ function forgeQuizBoxAnswer(quizObjectId, choiceIndex) {
else if (i === choiceIndex) buttons[i].classList.add('is-wrong');
}
}
if (correct) forgeAddQuizScore(state.rewardAmount);
var dialogueObjectId = state.dialogueObjectId;
setTimeout(function () {
if (widget) widget.classList.remove('is-active');
delete forgeQuizBoxState[quizObjectId];
forgeAdvanceConversation(dialogueObjectId);
}, correct ? FORGE_QUIZ_REVEAL_DELAY_CORRECT : FORGE_QUIZ_REVEAL_DELAY_WRONG);
window.forgeQuizTotalAnswered += 1;
if (correct) {
window.forgeQuizCorrectAnswered += 1;
forgeAddQuizScore(state.rewardAmount);
}
if (typeof forgeXapiNotifyQuestionAnswered === "function" && state.quest) {
forgeXapiNotifyQuestionAnswered(state.quest, 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" && state.quest) {
forgeScorm2004NotifyQuestionAnswered(state.quest, 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
// lent ou un utilisateur de lecteur d'écran — l'avancée dépend
// maintenant uniquement d'un clic sur "Continuer →", jamais d'un
// minuteur. Le bouton est ajouté APRÈS les choix (jamais en remplaçant
// choicesEl.innerHTML, qui effacerait le surlignage vert/rouge déjà posé).
if (widget) {
var choicesEl = widget.querySelector('[data-quiz-role="choices"]');
if (choicesEl) {
choicesEl.innerHTML += '<button type="button" class="quizBoxContinueBtn" onclick="forgeQuizBoxContinue(' + quizObjectId + ')">Continuer →</button>';
}
}
}
// Appelé par le bouton "Continuer →" ajouté par forgeQuizBoxAnswer
// ci-dessus — masque la boîte à quiz et avance la conversation, jamais
// automatique (voir le commentaire ci-dessus).
function forgeQuizBoxContinue(quizObjectId) {
var state = forgeQuizBoxState[quizObjectId];
if (!state) return;
var widget = forgeQuizBoxWidgetById(quizObjectId);
if (widget) widget.classList.remove('is-active');
delete forgeQuizBoxState[quizObjectId];
forgeAdvanceConversation(state.dialogueObjectId);
}
// ---------- Offre de quête (Accepter/Refuser) ----------
@@ -333,6 +381,7 @@ function forgeDialogueBoxAcceptQuest(questId, objectId) {
var quest = forgeQuestById(questId);
if (quest) quest.status = 'en_cours';
forgeSyncQuestStartedToScorm();
if (quest && typeof forgeXapiNotifyQuestAccepted === "function") forgeXapiNotifyQuestAccepted(quest);
var widget = forgeDialogueBoxWidgetById(objectId);
if (widget) widget.classList.remove('is-active');
delete forgeDialogueBoxState[objectId];
@@ -354,8 +403,9 @@ if (typeof module !== 'undefined' && module.exports) {
forgeQuestById, forgeShowDialogueBox, forgeDialogueBoxAdvance,
forgeDialogueBoxWidgetOnCurrentScreen, forgeDialogueBoxWidgetById, forgeRenderDialogueBoxLine,
forgeShowQuestOffer, forgeDialogueBoxAcceptQuest, forgeDialogueBoxDeclineQuest, forgeResetDialogueBoxFooter,
forgeShowQuizBox, forgeQuizBoxAnswer, forgeQuizBoxWidgetOnCurrentScreen, forgeQuizBoxWidgetById,
forgeShowQuizBox, forgeQuizBoxAnswer, forgeQuizBoxContinue, forgeQuizBoxWidgetOnCurrentScreen, forgeQuizBoxWidgetById,
forgeUpdateAllScoreWidgets, forgeAddQuizScore, forgeAdvanceConversation, forgeShowCurrentConversationLine,
forgeSyncQuizScoreToScorm, forgeSyncQuestStartedToScorm, forgeSyncAllQuestsCompletionToScorm,
forgeComputeFinalScormStatus,
};
}
@@ -43,6 +43,18 @@ test('image — self-closing, pas de contenu ni enfants', () => {
assert.ok(html.includes('src="chat.png"'));
});
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 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 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 html = forgeRenderElementHtml(el, baseGameData(), {}, {});
@@ -58,6 +58,12 @@ test('icône — repli sur "star" si _icon_slug absent', () => {
assert.ok(html.includes("--icon-url:url(&#x27;icons/star.svg&#x27;)"));
});
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 html = forgeRenderElementHtml(el, baseGameData(), {}, {});
assert.ok(html.includes('aria-hidden="true"'));
});
// Régression signalée par un utilisateur (deux fois) : un chemin de
// fichier pour --icon-url, même corrigé pour être relatif à
// static/style.css (là où mask-image:var(--icon-url) le consomme), reste
@@ -89,6 +95,7 @@ test('personnage — lit gameData.personnage_animations (idle en premier)', () =
assert.ok(html.startsWith('<img '));
assert.ok(html.includes('src="/static/characters/zombie/idle0.png"'));
assert.ok(html.includes('data-personnage="1"'));
assert.ok(html.includes('alt=""')); // RGAA 1.1.1, voir render-element.js
});
test('jauge — pourcentage et couleur interpolée depuis un champ d\'objet', () => {
@@ -0,0 +1,76 @@
// Export Web/SCORM — pont SCORM 1.2 (voir ../scorm-api.js). Couvre en
// particulier cmi.core.score.min/max (ajouté pour corriger : "5% pour
// 1.2 mais 100% pour 2004 avec 1 seule question" — sans min/max, le LMS
// n'a que le score BRUT qu'il affiche parfois à tort avec un "%").
const test = require('node:test');
const assert = require('node:assert/strict');
global.window = global.window || {};
global.window.addEventListener = global.window.addEventListener || function () {};
const {
forgeScormFindApi, forgeScormInit, forgeScormSyncScore, FORGE_TO_SCORM_STATUS,
} = require('../scorm-api.js');
function fakeApi() {
const calls = [];
return {
calls,
LMSInitialize: () => calls.push(['LMSInitialize']),
LMSSetValue: (key, value) => calls.push(['LMSSetValue', key, value]),
LMSCommit: () => calls.push(['LMSCommit']),
};
}
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',
});
});
test('forgeScormFindApi remonte la chaîne parent jusqu\'à trouver window.API', () => {
const api = {};
const grandparent = { API: api };
const parent = { parent: grandparent };
const win = { parent };
assert.equal(forgeScormFindApi(win, 10), api);
});
test('forgeScormInit ne pose PAS cmi.core.score.min/max si le jeu n\'a aucune question de quiz (max=0)', () => {
const api = fakeApi();
global.window.API = api;
global.window.FORGE_MAX_QUIZ_SCORE = 0;
try {
forgeScormInit();
assert.ok(!api.calls.some(c => c[1] === 'cmi.core.score.max'));
} finally { delete global.window.API; delete global.window.FORGE_MAX_QUIZ_SCORE; }
});
test('forgeScormInit pose cmi.core.score.min/max une fois si le jeu a des questions de quiz', () => {
const api = fakeApi();
global.window.API = api;
global.window.FORGE_MAX_QUIZ_SCORE = 20;
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']]
);
} finally { delete global.window.API; delete global.window.FORGE_MAX_QUIZ_SCORE; }
});
test('forgeScormSyncScore envoie score.raw/lesson_status et Commit, une seule fois si rien ne change', () => {
const api = fakeApi();
global.window.API = api;
global.window.FORGE_MAX_QUIZ_SCORE = 0;
try {
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'));
forgeScormSyncScore({ scoring: { score: 7, status: 'reussi' } });
assert.equal(api.calls.length, callsAfterFirst);
} finally { delete global.window.API; delete global.window.FORGE_MAX_QUIZ_SCORE; }
});
@@ -0,0 +1,151 @@
// Export Web/SCORM — variante 2004 de scorm-api.js : vérifie surtout que
// "echoue" reste completion_status="completed" (l'apprenant est allé au
// bout) avec success_status="failed", contrairement à la 1.2 où un champ
// unique force "incomplete" dès que le statut est "failed" (voir le bug
// signalé par l'utilisateur).
const test = require('node:test');
const assert = require('node:assert/strict');
global.window = global.window || {};
global.window.addEventListener = global.window.addEventListener || function () {};
const {
forgeScorm2004FindApi, forgeScorm2004Init, forgeScorm2004SyncScore,
forgeScorm2004NotifyQuestionAnswered, forgeScorm2004Timestamp, FORGE_TO_SCORM2004_STATUS,
} = require('../scorm2004-api.js');
function fakeApi(interactionsCount) {
const calls = [];
return {
calls,
Initialize: () => calls.push(['Initialize']),
SetValue: (key, value) => calls.push(['SetValue', key, value]),
GetValue: key => (key === 'cmi.interactions._count' ? String(interactionsCount || 0) : ''),
Commit: () => calls.push(['Commit']),
};
}
test('FORGE_TO_SCORM2004_STATUS — "echoue" reste completion="completed" avec success="failed" (contrairement à la 1.2)', () => {
assert.deepEqual(FORGE_TO_SCORM2004_STATUS.echoue, { completion: 'completed', success: 'failed' });
});
test('FORGE_TO_SCORM2004_STATUS — "reussi" est completion="completed" avec success="passed"', () => {
assert.deepEqual(FORGE_TO_SCORM2004_STATUS.reussi, { completion: 'completed', success: 'passed' });
});
test('FORGE_TO_SCORM2004_STATUS — "termine" (aucune question posée) reste success="unknown"', () => {
assert.deepEqual(FORGE_TO_SCORM2004_STATUS.termine, { completion: 'completed', success: 'unknown' });
});
test('forgeScorm2004FindApi remonte la chaîne parent jusqu\'à trouver API_1484_11', () => {
const api = {};
const grandparent = { API_1484_11: api };
const parent = { parent: grandparent };
const win = { parent };
assert.equal(forgeScorm2004FindApi(win, 10), api);
});
test('forgeScorm2004Init pose cmi.score.min/max une fois si le jeu a des questions de quiz', () => {
const api = fakeApi();
global.window.API_1484_11 = api;
global.window.FORGE_MAX_QUIZ_SCORE = 20;
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']]
);
} finally { delete global.window.API_1484_11; delete global.window.FORGE_MAX_QUIZ_SCORE; }
});
test('forgeScorm2004SyncScore — "echoue" envoie completion_status="completed" ET success_status="failed" (pas "incomplete")', () => {
const api = fakeApi();
global.window.API_1484_11 = api;
global.window.forgeQuizTotalAnswered = 0;
try {
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']]
);
assert.ok(api.calls.some(c => c[0] === 'Commit'));
} finally { delete global.window.API_1484_11; }
});
test('forgeScorm2004SyncScore — n\'envoie rien de nouveau si score/statut n\'ont pas changé (évite de spammer Commit)', () => {
const api = fakeApi();
global.window.API_1484_11 = api;
// Score/statut distincts des autres tests : forgeScorm2004LastSync est
// un état de module partagé entre tous les tests de ce fichier (comme
// en production, où il ne doit survivre qu'à UNE seule partie).
try {
forgeScorm2004Init();
forgeScorm2004SyncScore({ scoring: { score: 9, status: 'en_cours' } });
const callsAfterFirst = api.calls.length;
assert.ok(callsAfterFirst > 0);
forgeScorm2004SyncScore({ scoring: { score: 9, status: 'en_cours' } });
assert.equal(api.calls.length, callsAfterFirst);
} finally { delete global.window.API_1484_11; }
});
test('forgeScorm2004SyncScore — ajoute cmi.score.scaled UNIQUEMENT si des questions ont été répondues', () => {
const api = fakeApi();
global.window.API_1484_11 = api;
global.window.forgeQuizTotalAnswered = 4;
global.window.forgeQuizCorrectAnswered = 3;
try {
forgeScorm2004Init();
forgeScorm2004SyncScore({ scoring: { score: 3, status: 'reussi' } });
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;
delete global.window.forgeQuizTotalAnswered;
delete global.window.forgeQuizCorrectAnswered;
}
});
test('forgeScorm2004Timestamp — pas de millisecondes ni de "Z" (rejetés par certains LMS, voir capture SCORM Cloud avec timestamp vide)', () => {
const ts = forgeScorm2004Timestamp();
assert.match(ts, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/);
});
test('forgeScorm2004NotifyQuestionAnswered pose une entrée cmi.interactions.n.* complète (bonne réponse)', () => {
const api = fakeApi(2); // 2 interactions déjà enregistrées -> prochain indice = 2
global.window.API_1484_11 = api;
try {
forgeScorm2004Init();
forgeScorm2004NotifyQuestionAnswered({ id: 5, title: 'La quête' }, 1, 'Qui es-tu ?', true, 0, 0);
const sets = Object.fromEntries(api.calls.filter(c => c[0] === 'SetValue').map(c => [c[1], c[2]]));
assert.equal(sets['cmi.interactions.2.id'], 'quest: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}$/);
assert.equal(sets['cmi.interactions.2.description'], '{fr-FR}Qui es-tu ?');
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'));
} finally { delete global.window.API_1484_11; }
});
test('forgeScorm2004NotifyQuestionAnswered — mauvaise réponse : result="incorrect", learner_response ≠ correct_responses', () => {
const api = fakeApi(0);
global.window.API_1484_11 = api;
try {
forgeScorm2004Init();
forgeScorm2004NotifyQuestionAnswered({ id: 5, title: 'La quête' }, 0, 'Qui es-tu ?', false, 1, 0);
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');
} finally { delete global.window.API_1484_11; }
});
test('forgeScorm2004NotifyQuestionAnswered ne fait rien hors LMS (forgeScorm2004Api jamais résolu)', () => {
delete global.window.API_1484_11;
forgeScorm2004Init(); // aucune API trouvée -> remet forgeScorm2004Api à null
// Si le repli silencieux (if (!forgeScorm2004Api) return;) était absent,
// ceci lèverait en tentant d'appeler .GetValue sur null.
assert.doesNotThrow(() => forgeScorm2004NotifyQuestionAnswered({ id: 1 }, 0, 'x', true, 0, 0));
});
@@ -0,0 +1,93 @@
// Export Web/SCORM — bolt-on xAPI (voir ../xapi-client.js) : vérifie la
// granularité par quête/question demandée par l'utilisateur (quête
// acceptée/terminée, réponse à une question), en plus du score/statut
// global déjà couvert par dialogue-box-controller.test.js.
const test = require('node:test');
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'));
const {
forgeXapiNotifyQuestAccepted, forgeXapiNotifyQuestCompleted, forgeXapiNotifyQuestionAnswered,
forgeXapiQuestObject, forgeXapiQuestionObject,
} = require('../xapi-client.js');
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',
};
});
test('forgeXapiQuestObject construit un id de sous-activité dérivé de l\'activité du jeu', () => {
const obj = forgeXapiQuestObject({ id: 5, title: 'La quête du sorcier' });
assert.equal(obj.id, 'urn:forge-engine:game:test:quest:5');
assert.equal(obj.definition.name['fr-FR'], 'La quête du sorcier');
});
test('forgeXapiQuestionObject construit un id dérivé incluant la quête ET l\'index de question', () => {
const obj = forgeXapiQuestionObject({ id: 5, title: 'La quête du sorcier' }, 2, 'Qui es-tu ?');
assert.equal(obj.id, 'urn:forge-engine:game:test:quest:5:question:2');
assert.equal(obj.definition.description['fr-FR'], 'Qui es-tu ?');
});
test('forgeXapiNotifyQuestAccepted envoie un statement "attempted" ciblant la quête', () => {
const calls = [];
global.fetch = (url, opts) => { calls.push({ url, opts }); return Promise.resolve(); };
forgeXapiNotifyQuestAccepted({ id: 5, title: 'La quête du sorcier' });
assert.equal(calls.length, 1);
const body = JSON.parse(calls[0].opts.body);
assert.equal(body.verb.id, 'http://adlnet.gov/expapi/verbs/attempted');
assert.equal(body.object.id, 'urn:forge-engine:game:test:quest:5');
});
test('forgeXapiNotifyQuestCompleted envoie un statement "completed" ciblant la quête (result.completion=true)', () => {
const calls = [];
global.fetch = (url, opts) => { calls.push({ url, opts }); return Promise.resolve(); };
forgeXapiNotifyQuestCompleted({ id: 5, title: 'La quête du sorcier' });
const body = JSON.parse(calls[0].opts.body);
assert.equal(body.verb.id, 'http://adlnet.gov/expapi/verbs/completed');
assert.equal(body.object.id, 'urn:forge-engine:game:test:quest:5');
assert.equal(body.result.completion, true);
});
test('forgeXapiNotifyQuestionAnswered envoie un statement "answered" avec success/score, ciblant la question', () => {
const calls = [];
global.fetch = (url, opts) => { calls.push({ url, opts }); return Promise.resolve(); };
forgeXapiNotifyQuestionAnswered({ id: 5, title: 'La quête du 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.object.id, 'urn:forge-engine:game:test:quest:5:question:1');
assert.equal(body.result.success, true);
assert.equal(body.result.score.raw, 5);
});
test('une mauvaise réponse envoie quand même un statement, avec success=false et score=0', () => {
const calls = [];
global.fetch = (url, opts) => { calls.push({ url, opts }); return Promise.resolve(); };
forgeXapiNotifyQuestionAnswered({ id: 5, title: 'La quête du sorcier' }, 0, 'Qui es-tu ?', false, 0);
const body = JSON.parse(calls[0].opts.body);
assert.equal(body.result.success, false);
assert.equal(body.result.score.raw, 0);
});
test('le libellé du verbe est fourni en anglais ET en français (LRS libre d\'afficher l\'un ou l\'autre)', () => {
const calls = [];
global.fetch = (url, opts) => { calls.push({ url, opts }); return Promise.resolve(); };
forgeXapiNotifyQuestAccepted({ id: 5, title: 'La quête du sorcier' });
const body = JSON.parse(calls[0].opts.body);
assert.equal(body.verb.display['en-US'], 'attempted');
assert.equal(body.verb.display['fr-FR'], 'commencé');
});
test('aucun envoi si FORGE_XAPI_CONFIG est null (xAPI non configuré pour ce jeu)', () => {
global.window.FORGE_XAPI_CONFIG = null;
let called = false;
global.fetch = () => { called = true; return Promise.resolve(); };
forgeXapiNotifyQuestAccepted({ id: 5, title: 'x' });
forgeXapiNotifyQuestCompleted({ id: 5, title: 'x' });
forgeXapiNotifyQuestionAnswered({ id: 5, title: 'x' }, 0, 'x', false, 0);
assert.equal(called, false);
});
+14 -2
View File
@@ -114,12 +114,17 @@ function forgeStyleString(el, parentFlexDirection, widgetKey) {
return baseStyle + (userStyle ? " " + userStyle + ";" : "");
}
// Port de attr_string.py.
// Port de attr_string.py. "alt" est le seul attribut dont la chaîne VIDE
// a un sens propre (RGAA/WCAG 1.1.1 : alt="" marque explicitement une
// image comme décorative, ce que "pas d'alt du tout" ne fait pas pour un
// 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];
if (v === null || v === undefined || v === "" || v === false) return;
if (v === null || v === undefined || v === false) return;
if (v === "" && k !== "alt") return;
parts.push(v === true ? forgeHtmlEscape(k) : forgeHtmlEscape(k) + '="' + forgeHtmlEscape(v) + '"');
});
return parts.join(" ");
@@ -275,6 +280,13 @@ function forgeRenderElementHtml(el, gameData, childrenMap, ctx, parentFlexDirect
var 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 (meta.selfClosing) {
return mark("<" + tag + " " + forgeAttrString(attrs, style) + ">");
}
@@ -86,6 +86,11 @@ function forgeRenderIcone(el, meta, gameData, childrenMap, ctx, parentFlexDirect
// 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 + "');";
// 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>";
}
@@ -137,6 +142,9 @@ function forgeRenderPersonnage(el, meta, gameData, childrenMap, ctx, parentFlexD
var frames = animations ? (animations.idle || animations[Object.keys(animations)[0]]) : null;
var 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) + ">";
}
+19 -2
View File
@@ -23,6 +23,17 @@ function forgeScormInit() {
forgeScormApi = forgeScormFindApi(window, 10);
if (!forgeScormApi) return; // ouvert hors LMS (test local) : dégrade en silence
forgeScormApi.LMSInitialize("");
// cmi.core.score.min/max (voir publish/build_scorm_package.py,
// db/quests/reward_budget.py) : 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;
if (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
@@ -56,11 +67,17 @@ if (typeof window !== "undefined") {
// 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.
setInterval(function () {
var forgeScormSyncTimer = setInterval(function () {
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") {
forgeScormSyncTimer.unref();
}
}
if (typeof module !== "undefined" && module.exports) {
module.exports = { forgeScormFindApi, forgeScormSyncScore, FORGE_TO_SCORM_STATUS };
module.exports = { forgeScormFindApi, forgeScormInit, forgeScormSyncScore, forgeScormFinish, FORGE_TO_SCORM_STATUS };
}
+136
View File
@@ -0,0 +1,136 @@
// Export Web/SCORM — variante SCORM 2004 (3rd/4th edition) de
// scorm-api.js : voir db/games/get_scorm_version.py, publish/
// scorm_manifest.py. Chargé À LA PLACE de scorm-api.js (jamais les deux
// à la fois, voir publish/build_scorm_package.py) quand le créateur du
// jeu choisit "2004" plutôt que "1.2" dans la modale d'export.
//
// Pourquoi une 2ᵉ version : SCORM 1.2 n'a qu'un seul champ
// (cmi.core.lesson_status) pour coder à la fois complétion ET réussite
// — "failed" y implique "incomplete" dans l'interprétation de la
// plupart des LMS (voir FORGE_TO_SCORM_STATUS, scorm-api.js). La 2004
// sépare cmi.completion_status ("completed"/"incomplete") et
// cmi.success_status ("passed"/"failed"/"unknown") : un apprenant qui va
// au bout de la formation mais échoue au seuil de réussite (voir
// db/games/get_success_threshold.py) est enfin reporté "completed" ET
// "failed", au lieu de rester à tort "incomplete".
// L'API 2004 vit dans une fenêtre ancêtre sous le nom API_1484_11 (pas
// "API", réservé à la 1.2) — même algorithme de remontée que SCORM 1.2.
function forgeScorm2004FindApi(win, triesLeft) {
if (!win || triesLeft <= 0) return null;
if (win.API_1484_11) return win.API_1484_11;
if (win.parent && win.parent !== win) return forgeScorm2004FindApi(win.parent, triesLeft - 1);
if (win.opener) return forgeScorm2004FindApi(win.opener, triesLeft - 1);
return null;
}
var forgeScorm2004Api = null;
var 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("");
// 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;
if (maxScore) {
forgeScorm2004Api.SetValue("cmi.score.min", "0");
forgeScorm2004Api.SetValue("cmi.score.max", String(maxScore));
}
}
// Statut Forge (voir db/constants.py, SCORM_STATUS_KEYS) -> couple
// (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" },
};
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;
if (key === forgeScorm2004LastSync) return;
forgeScorm2004LastSync = key;
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));
}
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,
// appelée au même endroit que forgeXapiNotifyQuestionAnswered) : le
// pendant SCORM natif de "answered" xAPI, pour un LMS qui affiche le
// détail question par question dans SA PROPRE interface sans avoir
// besoin d'un LRS xAPI séparé. N'existe qu'en 2004 — SCORM 1.2 n'a pas
// de modèle de données cmi.interactions.*. id/type/timestamp/description/
// learner_response/result/correct_responses.0.pattern : les champs
// minimaux du modèle "choice" (ADL RTE), un index de LMS lit
// cmi.interactions._count pour connaître le prochain indice libre.
// cmi.interactions.n.timestamp (type CMITimestamp, ADL RTE) : PAS de
// millisecondes ni de suffixe "Z" acceptés par tous les LMS — new Date()
// .toISOString() produit "…T19:49:39.923Z", que certains rejettent
// 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$/, "");
}
function forgeScorm2004NotifyQuestionAnswered(quest, 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", "quest:" + quest.id + ":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("");
}
if (typeof window !== "undefined") {
window.addEventListener("load", forgeScorm2004Init);
window.addEventListener("beforeunload", forgeScorm2004Finish);
var forgeScorm2004SyncTimer = setInterval(function () {
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") {
forgeScorm2004SyncTimer.unref();
}
}
if (typeof module !== "undefined" && module.exports) {
module.exports = {
forgeScorm2004FindApi, forgeScorm2004Init, forgeScorm2004SyncScore, forgeScorm2004Finish,
forgeScorm2004NotifyQuestionAnswered, forgeScorm2004Timestamp, FORGE_TO_SCORM2004_STATUS,
};
}
+102 -15
View File
@@ -21,9 +21,27 @@
// 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", display: "completed" },
reussi: { id: "http://adlnet.gov/expapi/verbs/passed", display: "passed" },
echoue: { id: "http://adlnet.gov/expapi/verbs/failed", display: "failed" },
termine: { id: "http://adlnet.gov/expapi/verbs/completed" },
reussi: { id: "http://adlnet.gov/expapi/verbs/passed" },
echoue: { id: "http://adlnet.gov/expapi/verbs/failed" },
};
// Libellé humain par verbe — l'id xAPI (http://adlnet.gov/expapi/verbs/...)
// reste TOUJOURS en anglais (identifiant technique fixé par le
// vocabulaire ADL, jamais traduit), mais le libellé affiché par le LRS
// peut porter plusieurs langues : demande utilisateur "c'est possible en
// 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" },
};
var forgeXapiLastStatusSent = null; // évite de renvoyer le même statement si le statut ne change pas réellement
@@ -54,17 +72,48 @@ function forgeXapiObject() {
};
}
// verbId/verbDisplay : voir FORGE_TO_XAPI_VERB, ou directement les
// verbes "initialized"/"terminated" (posés une seule fois chacun, voir
// plus bas) — result : objet xAPI "result" optionnel (score/completion/
// success), fusionné tel quel dans le statement.
function forgeXapiSendStatement(verbId, verbDisplay, result) {
// Sous-activités xAPI (voir static/js/play/dialogue-box-controller.js) :
// une quête/question n'est PAS l'activité de premier niveau du jeu
// (forgeXapiObject ci-dessus) mais une activité fille, identifiée par un
// id dérivé — assez pour que le LRS les distingue sans configuration
// supplémentaire côté créateur du jeu.
function forgeXapiQuestObject(quest) {
var config = window.FORGE_XAPI_CONFIG;
return {
objectType: "Activity",
id: config.activity_id + ":quest:" + quest.id,
definition: { name: { "fr-FR": quest.title || "Quête" }, type: "http://adlnet.gov/expapi/activities/simulation" },
};
}
function forgeXapiQuestionObject(quest, questionIndex, questionText) {
var config = window.FORGE_XAPI_CONFIG;
return {
objectType: "Activity",
id: config.activity_id + ":quest:" + quest.id + ":question:" + questionIndex,
definition: {
name: { "fr-FR": "Question " + (questionIndex + 1) + " — " + (quest.title || "Quête") },
description: { "fr-FR": questionText || "" },
type: "http://adlnet.gov/expapi/activities/cmi.interaction",
interactionType: "choice",
},
};
}
// verbId : voir FORGE_TO_XAPI_VERB/FORGE_XAPI_VERB_DISPLAY (le libellé
// affiché est dérivé automatiquement du verbe, jamais passé en argument)
// — result : objet xAPI "result" optionnel (score/completion/success),
// fusionné tel quel dans le statement. object : l'activité visée par CE
// statement — l'activité de premier niveau du jeu par défaut
// (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;
if (!config || !config.endpoint) return;
var statement = {
actor: forgeXapiActor(),
verb: { id: verbId, display: { "en-US": verbDisplay } },
object: forgeXapiObject(),
verb: { id: verbId, display: FORGE_XAPI_VERB_DISPLAY[verbId] || { "en-US": verbId } },
object: object || forgeXapiObject(),
timestamp: new Date().toISOString(),
};
if (result) statement.result = result;
@@ -92,17 +141,51 @@ function forgeXapiSendStatement(verbId, verbDisplay, result) {
function forgeXapiNotifyScoreChanged(newScore) {
if (!window.FORGE_XAPI_CONFIG) return;
forgeXapiSendStatement(
"http://adlnet.gov/expapi/verbs/scored", "scored",
"http://adlnet.gov/expapi/verbs/scored",
{ score: { raw: newScore } }
);
}
// Granularité quête/quiz (voir dialogue-box-controller.js) : demande
// explicite "il faut savoir quand une quête a été acceptée, à telle
// question quel point obtenu, bien répondu ou non" — un statement DÉDIÉ
// par évènement, distinct du score/statut global (forgeXapiNotify
// ScoreChanged/StatusChanged ci-dessus, qui restent l'activité de
// premier niveau).
function forgeXapiNotifyQuestAccepted(quest) {
if (!window.FORGE_XAPI_CONFIG) return;
// "attempted" (ADL standard) : pas de verbe dédié "accepted" dans le
// vocabulaire ADL de base — attempted rend fidèlement "le joueur s'est
// engagé sur cette quête".
forgeXapiSendStatement(
"http://adlnet.gov/expapi/verbs/attempted",
null, forgeXapiQuestObject(quest)
);
}
function forgeXapiNotifyQuestCompleted(quest) {
if (!window.FORGE_XAPI_CONFIG) return;
forgeXapiSendStatement(
"http://adlnet.gov/expapi/verbs/completed",
{ completion: true }, forgeXapiQuestObject(quest)
);
}
function forgeXapiNotifyQuestionAnswered(quest, questionIndex, questionText, correct, scoreAwarded) {
if (!window.FORGE_XAPI_CONFIG) return;
forgeXapiSendStatement(
"http://adlnet.gov/expapi/verbs/answered",
{ success: correct, score: { raw: scoreAwarded || 0 } },
forgeXapiQuestionObject(quest, questionIndex, questionText)
);
}
function forgeXapiNotifyStatusChanged(newStatus) {
if (!window.FORGE_XAPI_CONFIG) return;
var verb = FORGE_TO_XAPI_VERB[newStatus];
if (!verb || newStatus === forgeXapiLastStatusSent) return;
forgeXapiLastStatusSent = newStatus;
forgeXapiSendStatement(verb.id, verb.display, {
forgeXapiSendStatement(verb.id, {
completion: true,
success: newStatus !== "echoue",
});
@@ -110,13 +193,17 @@ function forgeXapiNotifyStatusChanged(newStatus) {
if (typeof window !== "undefined") {
window.addEventListener("load", function () {
if (window.FORGE_XAPI_CONFIG) forgeXapiSendStatement("http://adlnet.gov/expapi/verbs/initialized", "initialized");
if (window.FORGE_XAPI_CONFIG) forgeXapiSendStatement("http://adlnet.gov/expapi/verbs/initialized");
});
window.addEventListener("beforeunload", function () {
if (window.FORGE_XAPI_CONFIG) forgeXapiSendStatement("http://adlnet.gov/expapi/verbs/terminated", "terminated");
if (window.FORGE_XAPI_CONFIG) forgeXapiSendStatement("http://adlnet.gov/expapi/verbs/terminated");
});
}
if (typeof module !== "undefined" && module.exports) {
module.exports = { forgeXapiSendStatement, forgeXapiNotifyScoreChanged, forgeXapiNotifyStatusChanged, FORGE_TO_XAPI_VERB };
module.exports = {
forgeXapiSendStatement, forgeXapiNotifyScoreChanged, forgeXapiNotifyStatusChanged, FORGE_TO_XAPI_VERB,
forgeXapiNotifyQuestAccepted, forgeXapiNotifyQuestCompleted, forgeXapiNotifyQuestionAnswered,
forgeXapiQuestObject, forgeXapiQuestionObject, FORGE_XAPI_VERB_DISPLAY,
};
}
+36 -3
View File
@@ -96,6 +96,13 @@ function bindHoverTexts() {
});
}
// RGAA/WCAG 2.1.1 (clavier) : un objet "Au clic" n'était auparavant
// actionnable qu'à la souris (simple <div>/<img>, jamais un <button>/<a>
// natif — voir screens/scenes/render_scene_object.py) — inatteignable et
// inactivable au clavier. tabindex="0" + role="button" le rendent
// focusable (sauf s'il l'est déjà — un <button>/<a> auteur, ex. widget
// "bouton"/"lien", ne doit pas se voir écraser son rôle natif) ; Entrée/
// Espace déclenchent le même comportement que le clic.
function bindClicks() {
document.querySelectorAll('[data-element-id]').forEach(elDiv => {
const elementId = parseInt(elDiv.dataset.elementId, 10);
@@ -112,7 +119,12 @@ function bindClicks() {
if (elDiv.dataset.clickBound) return;
elDiv.dataset.clickBound = '1';
elDiv.style.cursor = 'pointer';
elDiv.addEventListener('click', (ev) => {
if (!elDiv.hasAttribute('tabindex')) elDiv.setAttribute('tabindex', '0');
const tag = elDiv.tagName;
if (!elDiv.hasAttribute('role') && tag !== 'BUTTON' && tag !== 'A' && tag !== 'INPUT') {
elDiv.setAttribute('role', 'button');
}
const activate = (ev) => {
ev.stopPropagation();
// Si l'élément cliqué (ou un de ses parents, jusqu'au Répéteur
// qui reçoit le gestionnaire) est en fait une LIGNE de Répéteur,
@@ -126,6 +138,12 @@ function bindClicks() {
// Exécute CHAQUE déclencheur "Au clic" posé sur cet élément, pas
// seulement le premier (voir le commentaire de findTriggerNodess).
triggers.forEach(trigger => runFlowFrom(trigger.node.id, trigger.screenId));
};
elDiv.addEventListener('click', activate);
elDiv.addEventListener('keydown', (ev) => {
if (ev.key !== 'Enter' && ev.key !== ' ' && ev.key !== 'Spacebar') return;
ev.preventDefault();
activate(ev);
});
}
});
@@ -138,6 +156,12 @@ function bindClicks() {
// (voir le commentaire sur TRIGGER_EVENTS dans screens/flow/constants.py
// — pas de retour automatique à l'état d'avant survol, un créateur qui en
// veut un doit poser l'action inverse sur "Fin du survol" lui-même).
// RGAA/WCAG 2.1.1 (clavier) : "Au survol"/"Fin du survol" ne réagissaient
// qu'à la souris (mouseenter/mouseleave) — focus/blur est l'équivalent
// clavier standard (recevoir le focus = "survoler", le perdre = "fin du
// survol"), donc un élément SEULEMENT concerné par le survol (jamais par
// bindClicks() ci-dessus) doit aussi devenir focusable pour rester
// atteignable au clavier.
function bindHoverTriggers() {
document.querySelectorAll('[data-element-id]').forEach(elDiv => {
if (elDiv.dataset.hoverTriggerBound) return;
@@ -146,11 +170,20 @@ function bindHoverTriggers() {
const leaveTriggers = findTriggerNodess(elementId, 'fin_survol');
if (!enterTriggers.length && !leaveTriggers.length) return;
elDiv.dataset.hoverTriggerBound = '1';
if (!elDiv.hasAttribute('tabindex')) elDiv.setAttribute('tabindex', '0');
// Exécute CHAQUE déclencheur trouvé, pas seulement le premier — voir
// le commentaire de findTriggerNodess (plusieurs déclencheurs
// indépendants sur le même élément/événement doivent tous s'exécuter).
if (enterTriggers.length) elDiv.addEventListener('mouseenter', () => enterTriggers.forEach(t => runFlowFrom(t.node.id, t.screenId)));
if (leaveTriggers.length) elDiv.addEventListener('mouseleave', () => leaveTriggers.forEach(t => runFlowFrom(t.node.id, t.screenId)));
if (enterTriggers.length) {
const onEnter = () => enterTriggers.forEach(t => runFlowFrom(t.node.id, t.screenId));
elDiv.addEventListener('mouseenter', onEnter);
elDiv.addEventListener('focus', onEnter);
}
if (leaveTriggers.length) {
const onLeave = () => leaveTriggers.forEach(t => runFlowFrom(t.node.id, t.screenId));
elDiv.addEventListener('mouseleave', onLeave);
elDiv.addEventListener('blur', onLeave);
}
});
}