Ajoute la fonctionnalite quiz autonome/plein ecran a la boite a quiz
Build and deploy / test-python (push) Successful in 12m10s
Build and deploy / test-js (push) Successful in 1m27s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped

Introduit la double categorie de modeles (boite de dialogue / page de
quiz plein ecran) avec plein ecran, minuteur, score integre et ecran de
resultat pour les modeles page ; ajoute les modeles "Manga" (boite et
page) et "Classique" (page), pilotables aussi par l'assistant IA Ruby.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-09-13 15:07:23 +02:00
co-authored by Claude Sonnet 5
parent 559331f9cf
commit 7db4803b93
26 changed files with 3173 additions and 60 deletions
@@ -14,9 +14,10 @@ global.gameData = {};
const {
forgeShowDialogueBox, forgeDialogueBoxAdvance, forgeCompleteDialogue,
forgeQuizBoxAnswer, forgeQuizBoxContinue, forgeUpdateAllScoreWidgets,
forgeQuizBoxAnswer, forgeQuizBoxContinue, forgeQuizBoxTimeout, forgeUpdateAllScoreWidgets,
forgeAddQuizScore, forgeSyncAllDialoguesCompletionToScorm,
forgeComputeFinalScormStatus,
forgeComputeFinalScormStatus, forgeEscapeCameraForFullscreen,
forgeQuizResultQuit,
} = require('../dialogue-box-controller.js');
function fakeWidget(objectId) {
@@ -28,7 +29,10 @@ function fakeWidget(objectId) {
const classes = new Set();
return {
getAttribute: attr => (attr === 'data-object-id' ? String(objectId) : null),
classList: { add: c => classes.add(c), remove: c => classes.delete(c), contains: c => classes.has(c) },
classList: {
add: c => classes.add(c), remove: c => classes.delete(c), contains: c => classes.has(c),
toggle: (c, force) => { const on = force === undefined ? !classes.has(c) : !!force; if (on) classes.add(c); else classes.delete(c); return on; },
},
querySelector: sel => {
if (sel === '[data-dialogue-role="header"]') return roles.header;
if (sel === '[data-dialogue-role="body"]') return roles.body;
@@ -39,6 +43,45 @@ function fakeWidget(objectId) {
};
}
// Bug signalé : "aucune différence, la taille de la fenêtre de jeu est
// la même alors qu'il n'y a plus de caméra" — ".playScreen.playScene"
// (templates/play.html) est transformé (centré via translate), ce qui
// casse position:fixed pour tout descendant : un plein écran ne peut
// couvrir le VRAI viewport qu'en s'échappant de ce conteneur, comme
// static/js/play/video-overlay.js le fait déjà pour son propre overlay.
test('forgeEscapeCameraForFullscreen déplace le widget en enfant de <body> et retient l\'écran d\'origine', () => {
const screenEl = { getAttribute: attr => (attr === 'data-screen-id' ? '7' : null) };
const attrs = {};
const classes = new Set();
const bodyChildren = [];
const body = { appendChild: el => { bodyChildren.push(el); el.parentNode = body; } };
const widget = {
parentNode: null,
closest: sel => (sel === '.playScreen' ? screenEl : null),
setAttribute: (k, v) => { attrs[k] = v; },
classList: { add: c => classes.add(c) },
};
const previousDoc = global.document;
global.document = { body };
try {
forgeEscapeCameraForFullscreen(widget);
assert.equal(bodyChildren[0], widget);
assert.equal(widget.parentNode, body);
assert.equal(attrs['data-owner-screen-id'], '7');
assert.equal(classes.has('quizBoxWidget--detachedForFullscreen'), true);
} finally { global.document = previousDoc; }
});
test('forgeEscapeCameraForFullscreen ne fait rien si déjà enfant de <body> (jamais déplacé deux fois)', () => {
const body = { appendChild: () => { throw new Error('ne doit pas être rappelé'); } };
const widget = { parentNode: body };
const previousDoc = global.document;
global.document = { body };
try {
assert.doesNotThrow(() => forgeEscapeCameraForFullscreen(widget));
} finally { global.document = previousDoc; }
});
test.beforeEach(() => {
global.window.currentScreenId = 1;
// Compteurs de réussite (voir forgeComputeFinalScormStatus) : remis à
@@ -228,27 +271,58 @@ function fakeQuizChoiceBtn() {
return { disabled: false, classList: { add: c => classes.add(c), contains: c => classes.has(c) }, _classes: classes };
}
function fakeQuizWidget(objectId, choiceCount) {
const roles = { header: { textContent: '' }, question: { textContent: '' }, choices: { innerHTML: '' } };
function fakeQuizWidget(objectId, choiceCount, attrs) {
attrs = attrs || {};
const roles = {
header: { textContent: '' }, question: { textContent: '' }, choices: { innerHTML: '' },
score: { textContent: '' }, timer: { textContent: '', classList: { add() {}, remove() {}, toggle() {} } },
};
// Posé désactivé par forgeShowQuizBox dès la question (jamais recréé
// par forgeQuizBoxAnswer, qui se contente de .disabled=false) — voir
// les tests dédiés plus bas. Un objet à part (pas dérivé de
// roles.choices.innerHTML, une simple chaîne dans ce mock) pour
// pouvoir observer .disabled/.textContent comme un vrai élément DOM.
const continueBtn = { disabled: true, textContent: 'Continuer →' };
const classes = new Set();
// querySelectorAll('.quizBoxChoiceBtn') n'a pas besoin de refléter le
// HTML réellement injecté dans roles.choices.innerHTML (voir
// forgeShowQuizBox) pour ces tests — juste le bon NOMBRE de boutons,
// marqués correct/wrong par forgeQuizBoxAnswer via ces mêmes objets.
const buttons = Array.from({ length: choiceCount }, () => fakeQuizChoiceBtn());
return {
getAttribute: attr => (attr === 'data-object-id' ? String(objectId) : null),
classList: { add: c => classes.add(c), remove: c => classes.delete(c), contains: c => classes.has(c) },
const widget = {
parentNode: null,
// Simule ".closest('.playScreen')" (voir forgeEscapeCameraForFullscreen) :
// un widget "posé sur l'écran courant" est toujours, au départ,
// l'enfant d'un ".playScreen" fictif portant le même id d'écran.
closest: sel => (sel === '.playScreen' ? { getAttribute: a => (a === 'data-screen-id' ? String(window.currentScreenId) : null) } : null),
getAttribute: attr => {
if (attr === 'data-object-id') return String(objectId);
if (attr in attrs) return attrs[attr];
return null;
},
setAttribute: (k, v) => { attrs[k] = v; },
classList: {
// Variadique (comme le vrai DOMTokenList.remove/add) : le moteur
// appelle parfois .remove('is-active', 'is-fullscreen') d'un coup
// (voir forgeQuizResultQuit/forgeHideQuizBoxOnCurrentScreen).
add: (...cs) => cs.forEach(c => classes.add(c)), remove: (...cs) => cs.forEach(c => classes.delete(c)),
contains: c => classes.has(c),
toggle: (c, force) => { const on = force === undefined ? !classes.has(c) : !!force; if (on) classes.add(c); else classes.delete(c); return on; },
},
querySelector: sel => {
if (sel === '[data-quiz-role="header"]') return roles.header;
if (sel === '[data-quiz-role="question"]') return roles.question;
if (sel === '[data-quiz-role="choices"]') return roles.choices;
if (sel === '[data-quiz-role="score"]') return roles.score;
if (sel === '[data-quiz-role="timer"]') return roles.timer;
if (sel === '.quizBoxContinueBtn') return continueBtn;
return null;
},
querySelectorAll: sel => (sel === '.quizBoxChoiceBtn' ? buttons : []),
offsetWidth: 100,
_roles: roles, _classes: classes, _buttons: buttons,
_roles: roles, _classes: classes, _buttons: buttons, _continueBtn: continueBtn,
};
return widget;
}
function docWithDialogueAndQuiz(dialogueWidget, quizWidget) {
@@ -260,7 +334,18 @@ function docWithDialogueAndQuiz(dialogueWidget, quizWidget) {
if (sel.indexOf('data-object-id="42"') !== -1) return dialogueWidget; // forgeDialogueBoxWidgetById
return null;
},
querySelectorAll: () => [], // aucun widget "🏆 Score" dans ces tests
querySelectorAll(sel) {
// aucun widget "🏆 Score" dans ces tests — seul le score AFFICHÉ
// DANS la boîte à quiz elle-même (voir forgeUpdateQuizBoxScoreDisplays)
// a besoin d'être résolu ici, via le rôle déjà exposé par
// fakeQuizWidget ci-dessus.
if (sel === '.quizBoxWidget [data-quiz-role="score"]') return quizWidget ? [quizWidget._roles.score] : [];
return [];
},
// Nécessaire pour un quiz PLEIN ÉCRAN (voir forgeEscapeCameraForFullscreen) :
// pas besoin de simuler un vrai retrait/réinsertion DOM pour ces
// tests, juste que l'appel ne plante pas.
body: { appendChild: () => {} },
};
}
@@ -274,6 +359,197 @@ test.beforeEach(() => {
window.forgeQuizScore = 0;
});
// Bug signalé : "j'ai créé un quiz à l'affichage de l'écran avec l'ia,
// mais quand je fais jouer rien ne s'affiche" — un quiz RH AUTONOME (que
// des questions, aucune réplique) n'a besoin d'AUCUNE "💬 Boîte de
// dialogue" posée, seulement d'une "❓ Boîte à quiz" ; forgeShowDialogueBox
// sortait pourtant en silence dès le départ faute de boîte de dialogue,
// empêchant même la première question de s'afficher.
function docWithQuizOnlyNoDialogueBox(quizWidget) {
return {
querySelector(sel) {
if (sel.indexOf('.quizBoxWidget') !== -1 && sel.indexOf('data-object-id') === -1) return quizWidget || null;
if (sel.indexOf('data-object-id="99"') !== -1) return quizWidget || null;
return null; // jamais de ".dialogueBoxWidget" sur cet écran
},
querySelectorAll: sel => (sel === '.quizBoxWidget [data-quiz-role="score"]' ? [quizWidget._roles.score] : []),
body: { appendChild: () => {} },
};
}
test('un quiz SANS aucune "💬 Boîte de dialogue" posée (que des questions) affiche bien la première question', () => {
const quizWidget = fakeQuizWidget(99, 2, { 'data-timer-mode': 'aucun' });
const previousDoc = global.document;
global.document = docWithQuizOnlyNoDialogueBox(quizWidget);
try {
forgeShowDialogueBox('d_30', 'Quiz', [
{ type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1 },
]);
assert.equal(quizWidget._classes.has('is-active'), true);
assert.equal(quizWidget._roles.question.textContent, 'Qui es-tu ?');
} finally { global.document = previousDoc; }
});
test('un quiz SANS boîte de dialogue : répondre avance/termine correctement (markCompleted inclus)', () => {
const quizWidget = fakeQuizWidget(99, 2, { 'data-timer-mode': 'aucun' });
const previousDoc = global.document;
global.document = docWithQuizOnlyNoDialogueBox(quizWidget);
try {
forgeShowDialogueBox('d_31', 'Quiz', [
{ type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1 },
], true); // markCompleted
forgeQuizBoxAnswer(99, 0); // bonne réponse
assert.equal(window.forgeQuizScore, 1);
forgeQuizBoxContinue(99); // -> plus aucune ligne, dialogue épuisé
assert.equal(window.forgeCompletedDialogueIds['d_31'], true);
assert.equal(quizWidget._classes.has('is-active'), false);
} finally { global.document = previousDoc; }
});
// ---------- Écran de résultat de fin de quiz ("classique" uniquement,
// voir static/js/play/quiz-box-templates.js::forgeQuizClassiqueOnQuizComplete)
// demande explicite : "la page de resultat est cassee... elle affiche un
// conteneur qui reaffiche le contenu du quiz au lieu du resultat" — le
// vrai moteur n'en avait aucun jusqu'ici. `forgeQuizTemplateEffects` est
// une variable GLOBALE (voir quiz-box-templates.js, jamais requise ici) :
// simulée au cas par cas, jamais définie par défaut — donc TOUS les
// tests ci-dessus (aucun modèle enregistré) continuent de suivre
// l'ancien chemin (masquage simple), inchangé. ----------
function withFakeTemplateEffects(effects, fn) {
const previous = global.forgeQuizTemplateEffects;
global.forgeQuizTemplateEffects = effects;
try { fn(); } finally { global.forgeQuizTemplateEffects = previous; }
}
test('forgeQuizBoxContinue à la DERNIÈRE question d\'un modèle avec écran de résultat : reste actif/plein écran, appelle onQuizComplete avec le score DE CE QUIZ', () => {
const quizWidget = fakeQuizWidget(99, 2, { 'data-timer-mode': 'aucun', 'data-fullscreen': '1', 'data-quiz-template': 'classique' });
const previousDoc = global.document;
let completeCalledWith = null;
global.document = docWithQuizOnlyNoDialogueBox(quizWidget);
withFakeTemplateEffects({ classique: { onQuizComplete: (w, result) => { completeCalledWith = result; } } }, () => {
try {
forgeShowDialogueBox('d_50', 'Quiz', [
{ type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1 },
]);
forgeQuizBoxAnswer(99, 0); // bonne réponse — seule et dernière question
forgeQuizBoxContinue(99);
assert.equal(quizWidget._classes.has('is-active'), true, 'reste visible pour montrer le résultat, pas masqué');
assert.equal(quizWidget._classes.has('is-fullscreen'), true);
assert.deepEqual(completeCalledWith, { correctCount: 1, totalCount: 1 });
} finally { global.document = previousDoc; }
});
});
test('forgeQuizBoxContinue AVANT la dernière question : jamais d\'écran de résultat, même avec un modèle qui en propose un', () => {
const quizWidget = fakeQuizWidget(99, 2, { 'data-timer-mode': 'aucun', 'data-fullscreen': '1', 'data-quiz-template': 'classique' });
const previousDoc = global.document;
let completeCalled = false;
global.document = docWithQuizOnlyNoDialogueBox(quizWidget);
withFakeTemplateEffects({ classique: { onQuizComplete: () => { completeCalled = true; } } }, () => {
try {
forgeShowDialogueBox('d_51', 'Quiz', [
{ type: 'question', text: 'Question 1 ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1 },
{ type: 'question', text: 'Question 2 ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1 },
]);
forgeQuizBoxAnswer(99, 0);
forgeQuizBoxContinue(99); // avance vers la question 2, pas la fin
assert.equal(completeCalled, false);
assert.equal(quizWidget._roles.question.textContent, 'Question 2 ?');
} finally { global.document = previousDoc; }
});
});
test('forgeQuizResultQuit (bouton de l\'écran de résultat) masque le widget PUIS tente de fermer l\'onglet — demande explicite : quitter carrément, jamais recommencer', () => {
const quizWidget = fakeQuizWidget(99, 2, { 'data-timer-mode': 'aucun', 'data-fullscreen': '1', 'data-quiz-template': 'classique' });
const previousDoc = global.document;
const previousClose = window.close;
let closeCalled = false;
window.close = () => { closeCalled = true; };
global.document = docWithQuizOnlyNoDialogueBox(quizWidget);
withFakeTemplateEffects({ classique: { onQuizComplete: () => {} } }, () => {
try {
forgeShowDialogueBox('d_52', 'Quiz', [
{ type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1 },
]);
forgeQuizBoxAnswer(99, 0);
forgeQuizBoxContinue(99); // -> écran de résultat, widget toujours actif
forgeQuizResultQuit(99);
assert.equal(quizWidget._classes.has('is-active'), false);
assert.equal(quizWidget._classes.has('is-fullscreen'), false);
// window.close() ne ferme RÉELLEMENT que les onglets ouverts par
// script (restriction navigateur, voir le commentaire dans
// dialogue-box-controller.js) — on ne peut donc tester ici que
// "l'appel a bien été tenté", jamais qu'un onglet a vraiment fermé.
assert.equal(closeCalled, true);
} finally { global.document = previousDoc; window.close = previousClose; }
});
});
test('forgeQuizResultQuit ne plante jamais si window.close n\'existe pas (navigateur qui ne l\'expose pas)', () => {
const quizWidget = fakeQuizWidget(99, 2, { 'data-timer-mode': 'aucun', 'data-fullscreen': '1', 'data-quiz-template': 'classique' });
const previousDoc = global.document;
const previousClose = window.close;
delete window.close;
global.document = docWithQuizOnlyNoDialogueBox(quizWidget);
withFakeTemplateEffects({ classique: { onQuizComplete: () => {} } }, () => {
try {
forgeShowDialogueBox('d_54', 'Quiz', [
{ type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1 },
]);
forgeQuizBoxAnswer(99, 0);
forgeQuizBoxContinue(99);
assert.doesNotThrow(() => forgeQuizResultQuit(99));
} finally { global.document = previousDoc; window.close = previousClose; }
});
});
test('forgeQuizResultQuit ne fait rien sans widget correspondant', () => {
const previousDoc = global.document;
global.document = { querySelector: () => null };
try {
assert.doesNotThrow(() => forgeQuizResultQuit(999999));
} finally { global.document = previousDoc; }
});
test('forgeQuizBoxTimeout (minuteur du quiz ENTIER expiré) affiche l\'écran de résultat même s\'il restait des questions', (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const quizWidget = fakeQuizWidget(99, 2, { 'data-timer-mode': 'quiz', 'data-timer-seconds': '5', 'data-fullscreen': '1', 'data-quiz-template': 'classique' });
const previousDoc = global.document;
let completeCalledWith = null;
global.document = docWithQuizOnlyNoDialogueBox(quizWidget);
withFakeTemplateEffects({ classique: { onQuizComplete: (w, result) => { completeCalledWith = result; } } }, () => {
try {
forgeShowDialogueBox('d_53', 'Quiz', [
{ type: 'question', text: 'Question 1 ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1 },
{ type: 'question', text: 'Question 2 ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1 },
]);
forgeQuizBoxTimeout(99, true); // encore la question 2 à venir
t.mock.timers.tick(1500);
assert.deepEqual(completeCalledWith, { correctCount: 0, totalCount: 1 });
assert.equal(quizWidget._classes.has('is-active'), true);
} finally { global.document = previousDoc; }
});
});
test('"Continuer →" est TOUJOURS présent dès la question (désactivé), jamais recréé — juste activé par une réponse (demande explicite, comme la maquette manga)', () => {
const dialogueWidget = fakeWidget(42);
const quizWidget = fakeQuizWidget(99, 2);
const previousDoc = global.document;
global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget);
try {
forgeShowDialogueBox('d_60', 'Le sorcier', QUIZ_LINES);
forgeDialogueBoxAdvance(42); // -> question
// Dès l'affichage de la question, AVANT toute réponse : présent
// dans le balisage des choix ET désactivé.
assert.match(quizWidget._roles.choices.innerHTML, /forgeQuizBoxContinue\(99\)/);
assert.equal(quizWidget._continueBtn.disabled, true);
forgeQuizBoxAnswer(99, 1); // bonne réponse (correct_index=1)
// Le MÊME objet, juste activé — jamais un nouveau bouton recréé.
assert.equal(quizWidget._continueBtn.disabled, false);
} finally { global.document = previousDoc; }
});
test('une ligne "question" est affichée dans la boîte à QUIZ (header = nom de l\'objet déclencheur, corps = question+choix), pas la boîte de dialogue', () => {
const dialogueWidget = fakeWidget(42);
dialogueWidget.getAttribute = attr => (attr === 'data-object-id' ? '42' : null);
@@ -372,11 +648,120 @@ test('aucune boîte à quiz posée sur la scène : la question est ignorée, la
} finally { global.document = previousDoc; }
});
// ---------- Plein écran + score affiché (demande explicite) ----------
test('forgeShowQuizBox pose ".is-fullscreen" quand data-fullscreen="1", jamais sinon', () => {
const dialogueWidget = fakeWidget(42);
const quizWidgetOn = fakeQuizWidget(99, 2, { 'data-fullscreen': '1', 'data-timer-mode': 'aucun' });
const previousDoc = global.document;
global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidgetOn);
try {
forgeShowDialogueBox('d_10', 'Le sorcier', QUIZ_LINES);
forgeDialogueBoxAdvance(42);
assert.equal(quizWidgetOn._classes.has('is-fullscreen'), true);
} finally { global.document = previousDoc; }
const quizWidgetOff = fakeQuizWidget(99, 2, { 'data-fullscreen': '0', 'data-timer-mode': 'aucun' });
global.document = docWithDialogueAndQuiz(fakeWidget(42), quizWidgetOff);
try {
forgeShowDialogueBox('d_11', 'Le sorcier', QUIZ_LINES);
forgeDialogueBoxAdvance(42);
assert.equal(quizWidgetOff._classes.has('is-fullscreen'), false);
} finally { global.document = previousDoc; }
});
test('le score affiché DANS la boîte à quiz (data-quiz-role="score") se met à jour à chaque question et bonne réponse', () => {
const dialogueWidget = fakeWidget(42);
const quizWidget = fakeQuizWidget(99, 2, { 'data-timer-mode': 'aucun' });
const previousDoc = global.document;
global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget);
try {
forgeShowDialogueBox('d_10', 'Le sorcier', QUIZ_LINES);
forgeDialogueBoxAdvance(42);
assert.equal(quizWidget._roles.score.textContent, 'Score : 0');
forgeQuizBoxAnswer(99, 1); // bonne réponse, +5
assert.equal(quizWidget._roles.score.textContent, 'Score : 5');
} finally { global.document = previousDoc; }
});
// ---------- Minuteur (optionnel, "aucun" par défaut — demande explicite :
// "pouvoir paramétrer un timer pour répondre aux questions") ----------
test('minuteur "par question" expiré : révèle la bonne réponse (aucun bouton marqué faux), puis "Continuer →" fonctionne normalement', (t) => {
t.mock.timers.enable({ apis: ['setInterval', 'setTimeout'] });
const dialogueWidget = fakeWidget(42);
const quizWidget = fakeQuizWidget(99, 2, { 'data-timer-mode': 'question', 'data-timer-seconds': '10' });
const previousDoc = global.document;
global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget);
try {
forgeShowDialogueBox('d_10', 'Le sorcier', QUIZ_LINES);
forgeDialogueBoxAdvance(42); // -> question, démarre le minuteur (10s)
t.mock.timers.tick(10000); // le minuteur expire tout seul
assert.equal(quizWidget._buttons[1]._classes.has('is-correct'), true); // la bonne réponse (index 1) révélée
assert.equal(quizWidget._buttons[0]._classes.has('is-wrong'), false); // aucun clic -> aucun bouton "faux"
assert.equal(window.forgeQuizScore, 0); // comptée fausse, aucun point
assert.equal(window.forgeQuizTotalAnswered, 1);
forgeQuizBoxContinue(99); // "Continuer →" fonctionne normalement après un minuteur expiré
assert.equal(dialogueWidget._roles.body.textContent, 'Bien joué !');
} finally { global.document = previousDoc; }
});
test('un minuteur "par question" toujours en cours est arrêté dès qu\'on répond (jamais de double expiration)', (t) => {
t.mock.timers.enable({ apis: ['setInterval', 'setTimeout'] });
const dialogueWidget = fakeWidget(42);
const quizWidget = fakeQuizWidget(99, 2, { 'data-timer-mode': 'question', 'data-timer-seconds': '10' });
const previousDoc = global.document;
global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget);
try {
forgeShowDialogueBox('d_10', 'Le sorcier', QUIZ_LINES);
forgeDialogueBoxAdvance(42);
forgeQuizBoxAnswer(99, 1); // répond avant l'expiration
assert.equal(window.forgeQuizScore, 5);
t.mock.timers.tick(10000); // le minuteur, s'il tournait encore, exploserait ici
assert.equal(window.forgeQuizTotalAnswered, 1); // toujours une seule réponse comptée
} finally { global.document = previousDoc; }
});
test('minuteur "pour tout le quiz" expiré : révèle la bonne réponse PUIS termine le quiz tout seul (markCompleted/thenAction inclus)', (t) => {
t.mock.timers.enable({ apis: ['setInterval', 'setTimeout'] });
const dialogueWidget = fakeWidget(42);
const quizWidget = fakeQuizWidget(99, 2, { 'data-timer-mode': 'quiz', 'data-timer-seconds': '5' });
const previousDoc = global.document;
global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget);
try {
forgeShowDialogueBox('d_20', 'Le sorcier', QUIZ_LINES, true); // markCompleted=true
forgeDialogueBoxAdvance(42); // -> question, démarre le minuteur "quiz" (5s)
t.mock.timers.tick(5000); // expire : révèle la bonne réponse
assert.equal(quizWidget._buttons[1]._classes.has('is-correct'), true);
assert.equal(window.forgeCompletedDialogueIds['d_20'], undefined); // pas encore, le délai de lecture tourne
t.mock.timers.tick(1500); // délai de lecture écoulé -> fin forcée du quiz
assert.equal(window.forgeCompletedDialogueIds['d_20'], true);
assert.equal(quizWidget._classes.has('is-active'), false);
assert.equal(quizWidget._classes.has('is-fullscreen'), false);
} finally { global.document = previousDoc; }
});
test('forgeQuizBoxTimeout n\'a aucun effet si la question est déjà résolue (pas de double révélation)', () => {
const dialogueWidget = fakeWidget(42);
const quizWidget = fakeQuizWidget(99, 2, { 'data-timer-mode': 'question', 'data-timer-seconds': '10' });
const previousDoc = global.document;
global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget);
try {
forgeShowDialogueBox('d_10', 'Le sorcier', QUIZ_LINES);
forgeDialogueBoxAdvance(42);
forgeQuizBoxAnswer(99, 1); // déjà répondu
assert.doesNotThrow(() => forgeQuizBoxTimeout(99, false));
assert.equal(window.forgeQuizTotalAnswered, 1); // pas compté deux fois
} finally { global.document = previousDoc; }
});
test('forgeUpdateAllScoreWidgets — met à jour tous les widgets "🏆 Score" affichés', () => {
const values = [{ textContent: '' }, { textContent: '' }];
const previousDoc = global.document;
window.forgeQuizScore = 42;
global.document = { querySelectorAll: () => values };
global.document = {
querySelectorAll: sel => (sel === '.scoreWidget [data-score-role="value"]' ? values : []),
};
try {
forgeUpdateAllScoreWidgets();
assert.equal(values[0].textContent, 42);
@@ -384,6 +769,22 @@ test('forgeUpdateAllScoreWidgets — met à jour tous les widgets "🏆 Score" a
} finally { global.document = previousDoc; }
});
test('forgeUpdateAllScoreWidgets — met aussi à jour le score affiché DANS une boîte à quiz plein écran', () => {
// Demande explicite : "lorsque la boîte à quiz prend tout l'écran, le
// score s'affiche dans la boîte à quiz" — voir
// forgeUpdateQuizBoxScoreDisplays, appelée par forgeUpdateAllScoreWidgets.
const quizScoreEl = { textContent: '' };
const previousDoc = global.document;
window.forgeQuizScore = 7;
global.document = {
querySelectorAll: sel => (sel === '.quizBoxWidget [data-quiz-role="score"]' ? [quizScoreEl] : []),
};
try {
forgeUpdateAllScoreWidgets();
assert.equal(quizScoreEl.textContent, 'Score : 7');
} finally { global.document = previousDoc; }
});
// ---------- Pont vers SCORM (gameData.scoring, voir static/js/play/offline/scorm-api.js) ----------
// Bug signalé : "les indicateurs qu'on utilise pour les dialogues
// devraient aussi servir aux indicateurs SCORM, or ce n'est pas le cas" —
@@ -500,7 +901,7 @@ test('répondre à une question notifie xAPI avec la bonne/mauvaise réponse et
const dialogueWidget = fakeWidget(42);
const quizWidget = {
getAttribute: attr => (attr === 'data-object-id' ? '99' : null),
classList: { add: () => {}, remove: () => {} },
classList: { add: () => {}, remove: () => {}, toggle: () => {} },
querySelector: sel => (sel.indexOf('choices') !== -1 ? { innerHTML: '' } : { textContent: '' }),
querySelectorAll: () => [],
};
@@ -527,7 +928,7 @@ test('répondre à une question notifie AUSSI le SCORM 2004 (cmi.interactions.n.
const dialogueWidget = fakeWidget(42);
const quizWidget = {
getAttribute: attr => (attr === 'data-object-id' ? '99' : null),
classList: { add: () => {}, remove: () => {} },
classList: { add: () => {}, remove: () => {}, toggle: () => {} },
querySelector: sel => (sel.indexOf('choices') !== -1 ? { innerHTML: '' } : { textContent: '' }),
querySelectorAll: () => [],
};
@@ -0,0 +1,225 @@
// Effets visuels par modèle de "❓ Boîte à quiz" (voir static/js/play/
// quiz-box-templates.js, appelé par dialogue-box-controller.js aux
// points d'extension du moteur partagé).
const test = require('node:test');
const assert = require('node:assert/strict');
global.window = global.window || {};
global.document = global.document || {
createElement() { return { style: {}, className: '', appendChild() {} }; },
};
const {
forgeQuizTemplateEffects,
forgeQuizMangaOnShow, forgeQuizMangaOnQuizComplete,
forgeQuizClassiqueOnShow, forgeQuizClassiqueOnQuizComplete,
} = require('../quiz-box-templates.js');
test('les 4 modèles sont bien enregistrés ("defaut"/"manga_dialogue" boîte de dialogue + "classique"/"manga" page de quiz)', () => {
assert.deepEqual(
Object.keys(forgeQuizTemplateEffects).sort(),
['classique', 'defaut', 'manga', 'manga_dialogue'],
);
});
test('le registre ne branche aucun effet pour les modèles "boîte de dialogue" ("defaut"/"manga_dialogue" restent proches du modèle de base, décoration CSS uniquement)', () => {
assert.deepEqual(forgeQuizTemplateEffects.defaut, {});
assert.deepEqual(forgeQuizTemplateEffects.manga_dialogue, {});
});
function fakeMangaWidget() {
const header = { textContent: 'PNJ' };
const progress = { innerHTML: '' };
const resultZone = { innerHTML: '' };
const classes = new Set();
return {
getAttribute(attr) { return attr === 'data-object-id' ? '42' : null; },
querySelector(sel) {
if (sel === '[data-quiz-role="header"]') return header;
if (sel === '[data-manga-role="progress"]') return progress;
if (sel === '[data-manga-role="result"]') return resultZone;
return null;
},
classList: { add: c => classes.add(c), remove: c => classes.delete(c), contains: c => classes.has(c) },
_header: header, _progress: progress, _resultZone: resultZone, _classes: classes,
};
}
test('forgeQuizMangaOnShow ("manga", onShow) affiche "Question N/M" (uniquement les lignes de type question) et une barre d\'étapes', () => {
global.forgeQuizBoxState = { 42: { dialogueObjectId: 7 } };
global.forgeDialogueBoxState = {
7: {
index: 3, // 0:dialogue 1:question 2:dialogue 3:question(courante) 4:question
lines: [
{ type: 'dialogue' },
{ type: 'question' },
{ type: 'dialogue' },
{ type: 'question' },
{ type: 'question' },
],
},
};
const widget = fakeMangaWidget();
forgeQuizMangaOnShow(widget);
assert.equal(widget._header.textContent, 'Question 2/3');
assert.equal(widget._progress.innerHTML, '<i class="is-done"></i><i class="is-current"></i><i class=""></i>');
delete global.forgeQuizBoxState;
delete global.forgeDialogueBoxState;
});
test('forgeQuizMangaOnShow ne plante jamais sans widget/état', () => {
assert.doesNotThrow(() => forgeQuizMangaOnShow(null));
delete global.forgeQuizBoxState;
delete global.forgeDialogueBoxState;
assert.doesNotThrow(() => forgeQuizMangaOnShow(fakeMangaWidget()));
global.forgeQuizBoxState = { 42: { dialogueObjectId: 7 } };
assert.doesNotThrow(() => forgeQuizMangaOnShow(fakeMangaWidget())); // pas de forgeDialogueBoxState
delete global.forgeQuizBoxState;
});
test('forgeQuizMangaOnShow ne touche à rien si le dialogue ne contient aucune question (total === 0)', () => {
global.forgeQuizBoxState = { 42: { dialogueObjectId: 7 } };
global.forgeDialogueBoxState = { 7: { index: 0, lines: [{ type: 'dialogue' }] } };
const widget = fakeMangaWidget();
forgeQuizMangaOnShow(widget);
assert.equal(widget._header.textContent, 'PNJ');
assert.equal(widget._progress.innerHTML, '');
delete global.forgeQuizBoxState;
delete global.forgeDialogueBoxState;
});
test('forgeQuizMangaOnShow retire "is-showing-result" (défensif — widget réutilisé pour un quiz déclenché à nouveau)', () => {
const widget = fakeMangaWidget();
widget.classList.add('is-showing-result');
forgeQuizMangaOnShow(widget);
assert.equal(widget._classes.has('is-showing-result'), false);
});
test('forgeQuizMangaOnQuizComplete remplit l\'écran de résultat manga (badge 合格/不合格), remplit la barre d\'étapes à 100% et bascule la RACINE du widget en "is-showing-result"', () => {
const widget = fakeMangaWidget();
const previousThreshold = window.FORGE_SUCCESS_THRESHOLD_PERCENT;
window.FORGE_SUCCESS_THRESHOLD_PERCENT = 70;
try {
forgeQuizMangaOnQuizComplete(widget, { correctCount: 4, totalCount: 5 });
assert.equal(widget._classes.has('is-showing-result'), true);
assert.match(widget._resultZone.innerHTML, /Score final : 4 \/ 5/);
assert.match(widget._resultZone.innerHTML, /mangaResultBadge pass/);
assert.match(widget._resultZone.innerHTML, /合格/);
assert.match(widget._resultZone.innerHTML, /forgeQuizResultQuit\(42\)/);
assert.equal(widget._progress.innerHTML, '<i class="is-done"></i>'.repeat(5));
} finally { window.FORGE_SUCCESS_THRESHOLD_PERCENT = previousThreshold; }
});
test('forgeQuizMangaOnQuizComplete affiche le badge "不合格" (échoué) sous le seuil de réussite', () => {
const widget = fakeMangaWidget();
const previousThreshold = window.FORGE_SUCCESS_THRESHOLD_PERCENT;
window.FORGE_SUCCESS_THRESHOLD_PERCENT = 70;
try {
forgeQuizMangaOnQuizComplete(widget, { correctCount: 1, totalCount: 5 });
assert.match(widget._resultZone.innerHTML, /mangaResultBadge fail/);
assert.match(widget._resultZone.innerHTML, /不合格/);
} finally { window.FORGE_SUCCESS_THRESHOLD_PERCENT = previousThreshold; }
});
test('forgeQuizMangaOnQuizComplete ne plante jamais sans widget/sans le conteneur de résultat', () => {
assert.doesNotThrow(() => forgeQuizMangaOnQuizComplete(null, { correctCount: 1, totalCount: 1 }));
assert.doesNotThrow(() => forgeQuizMangaOnQuizComplete({ querySelector: () => null }, { correctCount: 1, totalCount: 1 }));
});
// "classique" réutilise le même calcul générique (forgeQuizProgressOnShow)
// que "manga", juste avec son propre sélecteur de barre d'étapes
// (data-classique-role, jamais data-manga-role).
function fakeClassiqueWidget() {
const header = { textContent: 'PNJ' };
const progress = { innerHTML: '' };
return {
getAttribute(attr) { return attr === 'data-object-id' ? '42' : null; },
querySelector(sel) {
if (sel === '[data-quiz-role="header"]') return header;
if (sel === '[data-classique-role="progress"]') return progress;
return null;
},
_header: header, _progress: progress,
};
}
test('forgeQuizClassiqueOnShow ("classique", onShow) affiche "Question N/M" et une barre d\'étapes, comme "manga"', () => {
global.forgeQuizBoxState = { 42: { dialogueObjectId: 7 } };
global.forgeDialogueBoxState = {
7: { index: 1, lines: [{ type: 'question' }, { type: 'question' }] },
};
const widget = fakeClassiqueWidget();
forgeQuizClassiqueOnShow(widget);
assert.equal(widget._header.textContent, 'Question 2/2');
assert.equal(widget._progress.innerHTML, '<i class="is-done"></i><i class="is-current"></i>');
delete global.forgeQuizBoxState;
delete global.forgeDialogueBoxState;
});
test('forgeQuizClassiqueOnShow ne plante jamais sans widget/état', () => {
assert.doesNotThrow(() => forgeQuizClassiqueOnShow(null));
assert.doesNotThrow(() => forgeQuizClassiqueOnShow(fakeClassiqueWidget()));
});
// ---------- Écran de résultat ("classique" uniquement) ----------
function fakeClassiqueWidgetForResult() {
const contentClasses = new Set();
const content = { classList: { add: c => contentClasses.add(c), remove: c => contentClasses.delete(c), contains: c => contentClasses.has(c) } };
const resultZone = { innerHTML: '' };
const headerTitle = { textContent: 'Formation Sécurité Incendie' };
return {
getAttribute(attr) { return attr === 'data-object-id' ? '42' : null; },
querySelector(sel) {
if (sel === '.classiqueContent') return content;
if (sel === '[data-classique-role="result"]') return resultZone;
if (sel === '.classiqueHeaderTitle') return headerTitle;
return null;
},
_content: content, _contentClasses: contentClasses, _resultZone: resultZone, _headerTitle: headerTitle,
};
}
test('forgeQuizClassiqueOnQuizComplete remplit l\'écran de résultat (score, badge réussi) et bascule .classiqueContent en "is-showing-result"', () => {
const widget = fakeClassiqueWidgetForResult();
const previousThreshold = window.FORGE_SUCCESS_THRESHOLD_PERCENT;
window.FORGE_SUCCESS_THRESHOLD_PERCENT = 70;
try {
forgeQuizClassiqueOnQuizComplete(widget, { correctCount: 3, totalCount: 4 });
assert.equal(widget._contentClasses.has('is-showing-result'), true);
assert.match(widget._resultZone.innerHTML, /75%/);
assert.match(widget._resultZone.innerHTML, /3 bonnes réponses sur 4/);
assert.match(widget._resultZone.innerHTML, /Formation Sécurité Incendie/);
assert.match(widget._resultZone.innerHTML, /classiqueResultBadge pass/);
assert.match(widget._resultZone.innerHTML, /Module validé/);
assert.match(widget._resultZone.innerHTML, /forgeQuizResultQuit\(42\)/);
assert.match(widget._resultZone.innerHTML, /Quitter le quiz/);
} finally { window.FORGE_SUCCESS_THRESHOLD_PERCENT = previousThreshold; }
});
test('forgeQuizClassiqueOnQuizComplete affiche le badge "échoué" sous le seuil de réussite', () => {
const widget = fakeClassiqueWidgetForResult();
const previousThreshold = window.FORGE_SUCCESS_THRESHOLD_PERCENT;
window.FORGE_SUCCESS_THRESHOLD_PERCENT = 70;
try {
forgeQuizClassiqueOnQuizComplete(widget, { correctCount: 2, totalCount: 4 });
assert.match(widget._resultZone.innerHTML, /50%/);
assert.match(widget._resultZone.innerHTML, /classiqueResultBadge fail/);
assert.match(widget._resultZone.innerHTML, /Module non validé — seuil 70%/);
} finally { window.FORGE_SUCCESS_THRESHOLD_PERCENT = previousThreshold; }
});
test('forgeQuizClassiqueOnQuizComplete ne plante jamais sans widget/sans les éléments attendus', () => {
assert.doesNotThrow(() => forgeQuizClassiqueOnQuizComplete(null, { correctCount: 1, totalCount: 1 }));
assert.doesNotThrow(() => forgeQuizClassiqueOnQuizComplete({ querySelector: () => null }, { correctCount: 1, totalCount: 1 }));
});
test('forgeQuizClassiqueOnShow retire "is-showing-result" (défensif — un futur modèle "Recommencer" réutiliserait ce garde-fou)', () => {
const widget = fakeClassiqueWidgetForResult();
widget._content.classList.add('is-showing-result');
// fakeClassiqueWidgetForResult n'a pas les rôles attendus par
// forgeQuizProgressOnShow (header/progress) : seul le retrait de la
// classe nous intéresse ici, le reste no-op sans état global posé.
forgeQuizClassiqueOnShow(widget);
assert.equal(widget._contentClasses.has('is-showing-result'), false);
});