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; }
});