Enrichit le reporting SCORM/xAPI et met en conformité RGAA le player
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:
@@ -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('icons/star.svg')"));
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
@@ -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) + ">";
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user