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:
@@ -36,6 +36,10 @@ from .games.delete_game import delete_game
|
||||
from .games.move_game import move_game
|
||||
from .games.get_xapi_settings import get_xapi_settings
|
||||
from .games.set_xapi_settings import set_xapi_settings
|
||||
from .games.get_success_threshold import get_success_threshold
|
||||
from .games.set_success_threshold import set_success_threshold
|
||||
from .games.get_scorm_version import get_scorm_version
|
||||
from .games.set_scorm_version import set_scorm_version
|
||||
from .games.game_type_catalog import (
|
||||
ONBOARDING_TYPES, DEFAULT_ONBOARDING_TYPE,
|
||||
get_onboarding_type, get_onboarding_type_raw, set_onboarding_type,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
from ..connection import connect
|
||||
|
||||
DEFAULT_SCORM_VERSION = "1.2"
|
||||
VALID_SCORM_VERSIONS = ("1.2", "2004")
|
||||
|
||||
|
||||
def get_scorm_version(slug):
|
||||
"""Version SCORM exportée pour ce jeu (voir publish/build_scorm_package.py,
|
||||
publish/scorm_manifest.py) — '1.2' par défaut (compatibilité la plus
|
||||
large) ou '2004' (3rd/4th edition, sépare completion_status/
|
||||
success_status : voir static/js/play/offline/scorm2004-api.js, corrige
|
||||
la limite SCORM 1.2 où un statut unique doit coder à la fois
|
||||
complétion ET réussite)."""
|
||||
conn = connect(slug)
|
||||
row = conn.execute("SELECT value FROM _meta WHERE key = 'scorm_version'").fetchone()
|
||||
conn.close()
|
||||
if row is None or row["value"] not in VALID_SCORM_VERSIONS:
|
||||
return DEFAULT_SCORM_VERSION
|
||||
return row["value"]
|
||||
@@ -0,0 +1,19 @@
|
||||
from ..connection import connect
|
||||
|
||||
DEFAULT_SUCCESS_THRESHOLD_PERCENT = 70
|
||||
|
||||
|
||||
def get_success_threshold(slug):
|
||||
"""Seuil de réussite (% de bonnes réponses au quiz) de CE jeu — stocké
|
||||
dans _meta (clé 'success_threshold_percent'), même convention que
|
||||
get_xapi_settings.py. Détermine le statut SCORM/xAPI de fin de partie
|
||||
(reussi/echoue, voir static/js/play/dialogue-box-controller.js::
|
||||
forgeSyncAllQuestsCompletionToScorm) : 70% par défaut si jamais réglé."""
|
||||
conn = connect(slug)
|
||||
row = conn.execute(
|
||||
"SELECT value FROM _meta WHERE key = 'success_threshold_percent'"
|
||||
).fetchone()
|
||||
conn.close()
|
||||
if row is None or row["value"] in (None, ""):
|
||||
return DEFAULT_SUCCESS_THRESHOLD_PERCENT
|
||||
return int(row["value"])
|
||||
@@ -0,0 +1,15 @@
|
||||
from ..connection import connect
|
||||
from .get_scorm_version import VALID_SCORM_VERSIONS
|
||||
|
||||
|
||||
def set_scorm_version(slug, version):
|
||||
"""Enregistre la version SCORM exportée — voir get_scorm_version.py."""
|
||||
if version not in VALID_SCORM_VERSIONS:
|
||||
raise ValueError("version SCORM invalide : {!r}".format(version))
|
||||
conn = connect(slug)
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO _meta (key, value) VALUES ('scorm_version', ?)",
|
||||
(version,),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
@@ -0,0 +1,14 @@
|
||||
from ..connection import connect
|
||||
|
||||
|
||||
def set_success_threshold(slug, percent):
|
||||
"""Enregistre le seuil de réussite (0-100) — voir get_success_threshold.py."""
|
||||
percent = max(0, min(100, int(percent)))
|
||||
conn = connect(slug)
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO _meta (key, value) VALUES ('success_threshold_percent', ?)",
|
||||
(str(percent),),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return percent
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Déclaration d'accessibilité exportable (obligation légale RGAA pour
|
||||
tout service numérique concerné, voir roadmap phase 1.2) — PAS un audit
|
||||
certifié : un résumé honnête, généré à partir de ce que le moteur de jeu
|
||||
implémente RÉELLEMENT aujourd'hui (voir static/js/play/triggers.js,
|
||||
dialogue-box-controller.js, screens/scenes/render_scene_object.py), à
|
||||
compléter/faire valider par le créateur du jeu avant publication."""
|
||||
from datetime import date
|
||||
|
||||
_DECLARATION_TEMPLATE = """Déclaration d'accessibilité — {game_name}
|
||||
Générée le {today} par Forge Engine
|
||||
|
||||
Cette déclaration résume l'état d'accessibilité du jeu de formation
|
||||
"{game_name}" au regard du RGAA (Référentiel Général d'Amélioration de
|
||||
l'Accessibilité, version française de WCAG 2.1 niveau AA). Elle décrit ce
|
||||
que le moteur Forge Engine implémente pour TOUT jeu exporté — elle ne
|
||||
remplace pas un audit RGAA complet réalisé par un expert accessibilité, et
|
||||
ne couvre pas le contenu propre à ce jeu (texte, images, choix de
|
||||
couleurs) qui reste sous la responsabilité de son créateur.
|
||||
|
||||
CRITÈRES PRIS EN CHARGE PAR LE MOTEUR
|
||||
--------------------------------------
|
||||
- Navigation clavier : tout objet de scène réagissant "Au clic" ou "Au
|
||||
survol" est focusable (Tab) et activable au clavier (Entrée/Espace),
|
||||
en plus de la souris. Les boutons de dialogue/quiz sont de vrais
|
||||
<button> HTML.
|
||||
- Lecteurs d'écran : les boîtes de dialogue et de quiz sont annoncées
|
||||
(role="dialog", contenu dynamique signalé par aria-live). Les images de
|
||||
personnages/décors reçoivent le nom donné par le créateur du jeu comme
|
||||
alternative textuelle, ou sont marquées décoratives (alt="") si aucun
|
||||
nom n'a été renseigné. Les icônes seules sont marquées décoratives.
|
||||
- Structure de page : landmark <main> et titre de page (masqué
|
||||
visuellement, lu par les lecteurs d'écran).
|
||||
- Délai réglable : la révélation de la bonne réponse à une question de
|
||||
quiz n'avance plus automatiquement après un délai fixe — l'apprenant
|
||||
clique sur "Continuer →" quand il est prêt.
|
||||
- Confort visuel : les animations en boucle infinie de l'interface
|
||||
respectent le réglage système "Réduire les animations"
|
||||
(prefers-reduced-motion).
|
||||
- Contraste des couleurs : un avertissement est affiché au créateur du
|
||||
jeu dans l'éditeur de style si le contraste texte/fond de la boîte de
|
||||
dialogue/quiz est insuffisant (< 4.5:1) — un choix de couleurs reste
|
||||
possible malgré l'avertissement, la vérification finale revient au
|
||||
créateur du jeu.
|
||||
|
||||
POINTS RESTANT À LA CHARGE DU CRÉATEUR DE CE JEU
|
||||
--------------------------------------------------
|
||||
- Choisir des couleurs de texte/fond suffisamment contrastées (le
|
||||
moteur avertit, mais n'empêche pas un choix insuffisant).
|
||||
- Fournir des noms clairs aux personnages/décors qui portent une
|
||||
information (le moteur ne peut pas deviner un contenu manquant).
|
||||
- Ajouter des sous-titres/transcriptions à tout contenu audio ajouté au
|
||||
jeu (hors du champ du moteur lui-même).
|
||||
- Vérifier que le texte des scènes reste compréhensible sans dépendre
|
||||
uniquement de la couleur ou de la position.
|
||||
|
||||
Cette déclaration a été générée automatiquement et n'engage que la
|
||||
description de ce que le moteur Forge Engine implémente à la date
|
||||
ci-dessus — elle ne constitue pas une certification de conformité RGAA
|
||||
complète du jeu "{game_name}".
|
||||
"""
|
||||
|
||||
|
||||
def build_accessibility_declaration(game_name):
|
||||
"""Texte brut (jamais de mise en forme HTML/Markdown à interpréter :
|
||||
prévu pour être lu/imprimé/collé tel quel dans un document interne,
|
||||
voir routes/publish/accessibility_declaration.py)."""
|
||||
return _DECLARATION_TEMPLATE.format(game_name=game_name, today=date.today().isoformat())
|
||||
@@ -258,6 +258,37 @@ def build_scorm_zip(slug):
|
||||
"activity_id": f"urn:forge-engine:game:{slug}",
|
||||
"activity_name": game["name"],
|
||||
}
|
||||
# Seuil de réussite (voir db/games/get_success_threshold.py) : % de
|
||||
# bonnes réponses au quiz requis pour que la fin de partie soit
|
||||
# reportée "reussi" plutôt que "echoue" (SCORM lesson_status ET
|
||||
# statement xAPI de fin, voir static/js/play/dialogue-box-controller.js
|
||||
# ::forgeSyncAllQuestsCompletionToScorm) — s'applique même sans xAPI
|
||||
# configuré, c'est aussi ce qui alimente le SCORM classique.
|
||||
success_threshold_percent = db.get_success_threshold(slug)
|
||||
|
||||
# Version SCORM (voir db/games/get_scorm_version.py) : '1.2' (défaut,
|
||||
# compatibilité la plus large) ou '2004' — seule la 2004 sépare
|
||||
# completion_status/success_status (static/js/play/offline/
|
||||
# scorm2004-api.js), corrige le "echoue" à tort reporté "incomplete"
|
||||
# en SCORM 1.2 (un seul champ pour coder complétion ET réussite).
|
||||
scorm_version = db.get_scorm_version(slug)
|
||||
scorm_api_wrapper_url = (
|
||||
"static/js/play/offline/scorm2004-api.js" if scorm_version == "2004"
|
||||
else "static/js/play/offline/scorm-api.js"
|
||||
)
|
||||
|
||||
# Score max possible (voir db/quests/reward_budget.py) : sans lui, un
|
||||
# LMS SCORM 1.2 n'a que cmi.core.score.raw (un total de points brut,
|
||||
# sans barème) et l'affiche à tort avec un "%" — demande utilisateur
|
||||
# "5% pour 1.2 mais 100% pour 2004 avec 1 seule question, c'est un
|
||||
# défaut de la 1.2 qu'on ne peut pas régler ?". En fournissant aussi
|
||||
# min/max (voir static/js/play/offline/scorm-api.js), le LMS peut
|
||||
# calculer raw/max lui-même, un vrai pourcentage au lieu du score brut
|
||||
# mal étiqueté.
|
||||
max_quiz_score = sum(
|
||||
db.quest_dialogues_total_reward(quest["dialogues"]) for quest in db.list_quests(slug)
|
||||
)
|
||||
|
||||
# gameData.icon_data_uris (voir forgeRenderIcone,
|
||||
# static/js/play/offline/render-special-widgets.js) : ajouté au
|
||||
# payload AVANT le rendu pour qu'il fasse partie du même payload_json
|
||||
@@ -272,13 +303,15 @@ def build_scorm_zip(slug):
|
||||
html = render_template(
|
||||
"play.html", game=game, screens_data=payload["screens"], payload_json=json.dumps(payload),
|
||||
offline_mode=True, asset_url=lambda filename: "static/" + filename,
|
||||
scorm_api_wrapper_url="static/js/play/offline/scorm-api.js",
|
||||
scorm_api_wrapper_url=scorm_api_wrapper_url,
|
||||
# "</" -> "<\\/" : endpoint/login sont saisis librement par le
|
||||
# créateur du jeu (voir routes/publish/xapi_settings.py) — sans
|
||||
# cet échappement, une valeur contenant "</script>" refermerait
|
||||
# prématurément la balise <script> de templates/play.html qui
|
||||
# embarque ce JSON (json.dumps n'échappe jamais "<"/">"/"&").
|
||||
xapi_config_json=json.dumps(xapi_config).replace("</", "<\\/"),
|
||||
success_threshold_percent=success_threshold_percent,
|
||||
max_quiz_score=max_quiz_score,
|
||||
)
|
||||
html = _relativize_absolute_urls(html, slug)
|
||||
html = _inline_icon_urls(html, payload["icon_data_uris"])
|
||||
@@ -286,7 +319,7 @@ def build_scorm_zip(slug):
|
||||
f.write(html)
|
||||
|
||||
package_files = _list_package_files(staging_dir)
|
||||
manifest_xml = build_manifest_xml(slug, game["name"], package_files)
|
||||
manifest_xml = build_manifest_xml(slug, game["name"], package_files, version=scorm_version)
|
||||
with open(os.path.join(staging_dir, "imsmanifest.xml"), "w", encoding="utf-8") as f:
|
||||
f.write(manifest_xml)
|
||||
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
"""Génère imsmanifest.xml (SCORM 1.2) pour le paquet Web/SCORM (voir
|
||||
build_scorm_package.py). SCORM 1.2 plutôt que 2004 : support le plus
|
||||
large chez les clients visés (Moodle, la plupart des LMS d'entreprise
|
||||
acceptent les deux, l'inverse est faux)."""
|
||||
"""Génère imsmanifest.xml pour le paquet Web/SCORM (voir
|
||||
build_scorm_package.py) — SCORM 1.2 par défaut (support le plus large
|
||||
chez les clients visés, Moodle et la plupart des LMS d'entreprise
|
||||
acceptent les deux, l'inverse est faux) ou 2004 3rd/4th edition au choix
|
||||
du créateur du jeu (voir db/games/get_scorm_version.py) : seule la 2004
|
||||
sépare cmi.completion_status/cmi.success_status, utile quand un
|
||||
"échoué" doit rester "completed" (l'apprenant est allé au bout) plutôt
|
||||
que retomber en "incomplete" comme l'impose le champ unique de la 1.2."""
|
||||
import os
|
||||
from xml.sax.saxutils import escape
|
||||
|
||||
_MANIFEST_TEMPLATE = """<?xml version="1.0" standalone="no" ?>
|
||||
_MANIFEST_TEMPLATE_1_2 = """<?xml version="1.0" standalone="no" ?>
|
||||
<manifest identifier="FORGE_{identifier}" version="1"
|
||||
xmlns="http://www.imsproject.org/xsd/imscp_rootv1p1p2"
|
||||
xmlns:adlcp="http://www.adlnet.org/xsd/adlcp_rootv1p2"
|
||||
@@ -33,16 +37,54 @@ _MANIFEST_TEMPLATE = """<?xml version="1.0" standalone="no" ?>
|
||||
</manifest>
|
||||
"""
|
||||
|
||||
_MANIFEST_TEMPLATE_2004 = """<?xml version="1.0" standalone="no" ?>
|
||||
<manifest identifier="FORGE_{identifier}" version="1"
|
||||
xmlns="http://www.imsglobal.org/xsd/imscp_v1p1"
|
||||
xmlns:adlcp="http://www.adlnet.org/xsd/adlcp_v1p3"
|
||||
xmlns:adlseq="http://www.adlnet.org/xsd/adlseq_v1p3"
|
||||
xmlns:adlnav="http://www.adlnet.org/xsd/adlnav_v1p3"
|
||||
xmlns:imsss="http://www.imsglobal.org/xsd/imsss"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.imsglobal.org/xsd/imscp_v1p1 imscp_v1p1.xsd
|
||||
http://www.adlnet.org/xsd/adlcp_v1p3 adlcp_v1p3.xsd
|
||||
http://www.adlnet.org/xsd/adlseq_v1p3 adlseq_v1p3.xsd
|
||||
http://www.adlnet.org/xsd/adlnav_v1p3 adlnav_v1p3.xsd
|
||||
http://www.imsglobal.org/xsd/imsss imsss_v1p0.xsd">
|
||||
<metadata>
|
||||
<schema>ADL SCORM</schema>
|
||||
<schemaversion>2004 4th Edition</schemaversion>
|
||||
</metadata>
|
||||
<organizations default="FORGE_ORG_{identifier}">
|
||||
<organization identifier="FORGE_ORG_{identifier}">
|
||||
<title>{title}</title>
|
||||
<item identifier="FORGE_ITEM_{identifier}" identifierref="FORGE_RES_{identifier}">
|
||||
<title>{title}</title>
|
||||
</item>
|
||||
</organization>
|
||||
</organizations>
|
||||
<resources>
|
||||
<resource identifier="FORGE_RES_{identifier}" type="webcontent" adlcp:scormType="sco" href="index.html">
|
||||
<file href="index.html"/>
|
||||
{file_entries}
|
||||
</resource>
|
||||
</resources>
|
||||
</manifest>
|
||||
"""
|
||||
|
||||
def build_manifest_xml(slug, game_name, package_files):
|
||||
_MANIFEST_TEMPLATES = {"1.2": _MANIFEST_TEMPLATE_1_2, "2004": _MANIFEST_TEMPLATE_2004}
|
||||
|
||||
|
||||
def build_manifest_xml(slug, game_name, package_files, version="1.2"):
|
||||
"""`package_files` : chemins relatifs (depuis la racine du paquet, tel
|
||||
qu'écrits dans le zip) de TOUS les fichiers autres que index.html —
|
||||
un LMS strict (SCORM Cloud) rejette un manifeste qui omet un fichier
|
||||
réellement présent dans le paquet."""
|
||||
réellement présent dans le paquet. `version` : '1.2' (défaut) ou
|
||||
'2004' — voir db/games/get_scorm_version.py."""
|
||||
file_entries = "\n".join(
|
||||
' <file href="{}"/>'.format(escape(rel.replace(os.sep, "/")))
|
||||
for rel in package_files
|
||||
)
|
||||
return _MANIFEST_TEMPLATE.format(
|
||||
template = _MANIFEST_TEMPLATES.get(version, _MANIFEST_TEMPLATE_1_2)
|
||||
return template.format(
|
||||
identifier=escape(slug), title=escape(game_name), file_entries=file_entries,
|
||||
)
|
||||
|
||||
@@ -1,2 +1,5 @@
|
||||
from . import export_scorm
|
||||
from . import xapi_settings
|
||||
from . import scoring_settings
|
||||
from . import scorm_version_settings
|
||||
from . import accessibility_declaration
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
from flask import Response
|
||||
|
||||
import db
|
||||
from publish.accessibility_declaration import build_accessibility_declaration
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/accessibility-declaration", methods=["GET"])
|
||||
def accessibility_declaration_get(slug):
|
||||
"""Télécharge la déclaration d'accessibilité de ce jeu (voir
|
||||
publish/accessibility_declaration.py) — même garde d'accès que
|
||||
/game/<slug>/export-scorm (core/auth_guard.py)."""
|
||||
game = db.game_meta(slug)
|
||||
text = build_accessibility_declaration(game["name"])
|
||||
download_name = f"{db.slugify(game['name'])}-declaration-accessibilite.txt"
|
||||
return Response(
|
||||
text, mimetype="text/plain; charset=utf-8",
|
||||
headers={"Content-Disposition": f'attachment; filename="{download_name}"'},
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
from flask import jsonify, request
|
||||
|
||||
import db
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/scoring-settings", methods=["GET"])
|
||||
def scoring_settings_get(slug):
|
||||
"""Seuil de réussite de ce jeu (voir db/games/get_success_threshold.py)
|
||||
— s'applique indépendamment du réglage xAPI (détermine aussi le
|
||||
lesson_status SCORM passed/failed, voir static/js/play/offline/
|
||||
scorm-api.js::FORGE_TO_SCORM_STATUS)."""
|
||||
return jsonify({"success_threshold_percent": db.get_success_threshold(slug)})
|
||||
|
||||
|
||||
@app.route("/game/<slug>/scoring-settings", methods=["POST"])
|
||||
def scoring_settings_set(slug):
|
||||
"""Enregistre le seuil de réussite (0-100) — voir db/games/set_success_threshold.py."""
|
||||
body = request.get_json(silent=True) or {}
|
||||
try:
|
||||
percent = int(body.get("success_threshold_percent"))
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({"ok": False, "error": "success_threshold_percent invalide"}), 400
|
||||
saved = db.set_success_threshold(slug, percent)
|
||||
return jsonify({"ok": True, "success_threshold_percent": saved})
|
||||
@@ -0,0 +1,23 @@
|
||||
from flask import jsonify, request
|
||||
|
||||
import db
|
||||
from db.games.get_scorm_version import VALID_SCORM_VERSIONS
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/scorm-version", methods=["GET"])
|
||||
def scorm_version_get(slug):
|
||||
"""Version SCORM exportée pour ce jeu (voir db/games/get_scorm_version.py)."""
|
||||
return jsonify({"scorm_version": db.get_scorm_version(slug)})
|
||||
|
||||
|
||||
@app.route("/game/<slug>/scorm-version", methods=["POST"])
|
||||
def scorm_version_set(slug):
|
||||
"""Enregistre la version SCORM exportée — voir db/games/set_scorm_version.py."""
|
||||
body = request.get_json(silent=True) or {}
|
||||
version = body.get("scorm_version")
|
||||
if version not in VALID_SCORM_VERSIONS:
|
||||
return jsonify({"ok": False, "error": "scorm_version invalide"}), 400
|
||||
db.set_scorm_version(slug, version)
|
||||
return jsonify({"ok": True, "scorm_version": version})
|
||||
@@ -58,7 +58,12 @@ def render_scene_object(obj):
|
||||
f"font-weight:{style['font_weight']}; color:{html_lib.escape(style['text_color'])};"
|
||||
)
|
||||
return (
|
||||
f'<div class="dialogueBoxWidget" data-object-id="{obj["id"]}" data-element-id="{obj["id"]}" style="{box_style}">'
|
||||
# role="dialog"/aria-live (RGAA/WCAG 4.1.3) : le texte de
|
||||
# dialogue change dynamiquement (innerHTML/textContent, voir
|
||||
# static/js/play/dialogue-box-controller.js) sans jamais
|
||||
# recharger la page — sans annonce explicite, un lecteur
|
||||
# d'écran ne détecte aucun changement.
|
||||
f'<div class="dialogueBoxWidget" role="dialog" aria-live="polite" aria-atomic="true" data-object-id="{obj["id"]}" data-element-id="{obj["id"]}" style="{box_style}">'
|
||||
f'<div class="dialogueBoxHeader" data-dialogue-role="header" style="background:{html_lib.escape(style["header_bg"])}">Nom du personnage</div>'
|
||||
f'<div class="dialogueBoxBody" data-dialogue-role="body" style="background:{html_lib.escape(style["body_bg"])}">Le texte du dialogue s\'affiche ici.</div>'
|
||||
f'<div class="dialogueBoxFooter" data-dialogue-role="footer" style="background:{html_lib.escape(style["footer_bg"])}">'
|
||||
@@ -83,7 +88,7 @@ def render_scene_object(obj):
|
||||
f"font-weight:{style['font_weight']}; color:{html_lib.escape(style['text_color'])};"
|
||||
)
|
||||
return (
|
||||
f'<div class="quizBoxWidget" data-object-id="{obj["id"]}" data-element-id="{obj["id"]}" style="{box_style}">'
|
||||
f'<div class="quizBoxWidget" role="dialog" aria-live="polite" aria-atomic="true" data-object-id="{obj["id"]}" data-element-id="{obj["id"]}" style="{box_style}">'
|
||||
f'<div class="quizBoxHeader" data-quiz-role="header" style="background:{html_lib.escape(style["header_bg"])}">Quête : Titre de la quête</div>'
|
||||
f'<div class="quizBoxBody" data-quiz-role="body" style="background:{html_lib.escape(style["body_bg"])}">'
|
||||
f'<p class="quizBoxQuestionText" data-quiz-role="question">La question s\'affiche ici.</p>'
|
||||
@@ -115,8 +120,14 @@ def render_scene_object(obj):
|
||||
if obj["kind"] == "personnage":
|
||||
src = resolve_personnage_idle_frame(obj)
|
||||
src_attr = f' src="{html_lib.escape(src)}"' if src else ""
|
||||
# alt (RGAA/WCAG 1.1.1) : le nom donné par l'auteur ("ℹ️
|
||||
# Informations", voir scene_object_names.py) s'il existe — c'est
|
||||
# déjà ce qu'un lecteur d'écran devrait annoncer ("qui parle" dans
|
||||
# un dialogue de quête réutilise le même nom). alt="" (décoratif)
|
||||
# sinon, jamais le nom de fichier brut par défaut du navigateur.
|
||||
alt_attr = f' alt="{html_lib.escape(obj.get("name") or "")}"'
|
||||
return (f'<img class="sceneObjectSprite" data-object-id="{obj["id"]}" data-element-id="{obj["id"]}" '
|
||||
f'data-anim-target="{obj["id"]}" data-personnage="1" style="{style}"{src_attr}>')
|
||||
f'data-anim-target="{obj["id"]}" data-personnage="1" style="{style}"{src_attr}{alt_attr}>')
|
||||
src = (obj.get("attributes") or {}).get("src", "")
|
||||
src_attr = f' src="{html_lib.escape(src)}"' if src else ""
|
||||
# "fond" (voir add_scene_object.py) : même image fixe que "decor",
|
||||
@@ -126,5 +137,9 @@ def render_scene_object(obj):
|
||||
# étendue sur tout le "monde" (voir personnage-controller.js, la
|
||||
# caméra qui la suit en défilant).
|
||||
css_class = "sceneObjectSprite sceneBackground" if obj["kind"] == "fond" else "sceneObjectSprite"
|
||||
# "fond" est TOUJOURS décoratif (un décor de scène entier, jamais une
|
||||
# information à transmettre) — un "decor" nommé par l'auteur utilise
|
||||
# ce nom, sinon alt="" plutôt qu'aucun attribut du tout.
|
||||
alt_attr = ' alt=""' if obj["kind"] == "fond" else f' alt="{html_lib.escape(obj.get("name") or "")}"'
|
||||
return (f'<img class="{css_class}" data-object-id="{obj["id"]}" data-element-id="{obj["id"]}" '
|
||||
f'data-anim-target="{obj["id"]}" style="{style}"{src_attr}>')
|
||||
f'data-anim-target="{obj["id"]}" style="{style}"{src_attr}{alt_attr}>')
|
||||
|
||||
@@ -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; }
|
||||
});
|
||||
|
||||
@@ -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('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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -606,6 +606,51 @@ function refreshSceneObjectNames() {
|
||||
.then(function (json) { SCENE_OBJECT_NAMES = json; });
|
||||
}
|
||||
|
||||
// RGAA/WCAG 1.4.3 (contraste minimum) : les couleurs du "🎨 Style"
|
||||
// ci-dessous sont librement choisies par l'auteur, sans aucune validation
|
||||
// serveur (voir sanitize_dialogue_box_style.py, qui n'accepte qu'une
|
||||
// chaîne de longueur bornée, jamais un ratio) — juste un AVERTISSEMENT
|
||||
// visuel dans l'éditeur, jamais un blocage (le créateur reste libre de
|
||||
// son choix esthétique, ex. un jeu volontairement sombre/stylisé).
|
||||
function forgeHexToRgb(hex) {
|
||||
hex = String(hex || '').replace('#', '');
|
||||
if (hex.length === 3) hex = hex.split('').map(function (c) { return c + c; }).join('');
|
||||
var num = parseInt(hex, 16);
|
||||
if (isNaN(num) || hex.length !== 6) return [0, 0, 0];
|
||||
return [(num >> 16) & 255, (num >> 8) & 255, num & 255];
|
||||
}
|
||||
|
||||
function forgeRelativeLuminance(rgb) {
|
||||
var srgb = rgb.map(function (c) {
|
||||
c = c / 255;
|
||||
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
||||
});
|
||||
return 0.2126 * srgb[0] + 0.7152 * srgb[1] + 0.0722 * srgb[2];
|
||||
}
|
||||
|
||||
// Formule de contraste WCAG standard (relative luminance) — 4.5:1 est le
|
||||
// seuil "AA" pour du texte normal (RGAA 3.2/critère 1.4.3).
|
||||
function forgeContrastRatio(hex1, hex2) {
|
||||
var l1 = forgeRelativeLuminance(forgeHexToRgb(hex1));
|
||||
var l2 = forgeRelativeLuminance(forgeHexToRgb(hex2));
|
||||
var lighter = Math.max(l1, l2), darker = Math.min(l1, l2);
|
||||
return (lighter + 0.05) / (darker + 0.05);
|
||||
}
|
||||
|
||||
function checkDialogueBoxContrast(style) {
|
||||
var warningEl = document.getElementById('dboxContrastWarning');
|
||||
if (!warningEl) return;
|
||||
var ratios = [
|
||||
forgeContrastRatio(style.text_color, style.header_bg),
|
||||
forgeContrastRatio(style.text_color, style.body_bg),
|
||||
forgeContrastRatio(style.text_color, style.footer_bg),
|
||||
];
|
||||
var minRatio = Math.min.apply(null, ratios);
|
||||
warningEl.textContent = minRatio < 4.5
|
||||
? '⚠️ Contraste insuffisant (' + minRatio.toFixed(1) + ':1, minimum recommandé 4.5:1) — le texte risque d\'être difficile à lire.'
|
||||
: '';
|
||||
}
|
||||
|
||||
// "🎨 Style" du widget "💬 Boîte de dialogue" (voir screens/rendering/
|
||||
// dialogue_box_style.py) — envoie TOUJOURS les 7 champs ensemble (même
|
||||
// convention "replace-all" que saveSceneObjectCollision), jamais un seul
|
||||
@@ -620,6 +665,7 @@ function saveDialogueBoxStyle(objectId) {
|
||||
body_bg: document.getElementById('dbox-body-bg').value,
|
||||
footer_bg: document.getElementById('dbox-footer-bg').value,
|
||||
};
|
||||
checkDialogueBoxContrast(style);
|
||||
var el = document.getElementById('el-' + objectId);
|
||||
var widget = el && el.querySelector('.dialogueBoxWidget');
|
||||
if (widget) {
|
||||
|
||||
@@ -785,6 +785,14 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d
|
||||
animation:quizCorrectPop .3s ease;
|
||||
}
|
||||
.quizBoxChoiceBtn.is-wrong{ background:#e0524d !important; border-color:#e0524d !important; animation:quizWrongShake .4s ease; }
|
||||
/* Bouton "Continuer →" (voir forgeQuizBoxAnswer/forgeQuizBoxContinue,
|
||||
RGAA/WCAG 2.2.1) — remplace l'ancienne avancée automatique après un
|
||||
délai fixe : l'apprenant décide lui-même quand passer à la suite. */
|
||||
.quizBoxContinueBtn{
|
||||
margin-top:4px; background:#2f6fed; color:#fff; border:1px solid #2f6fed;
|
||||
border-radius:6px; padding:8px 10px; font:inherit; font-size:13px; font-weight:600; cursor:pointer;
|
||||
}
|
||||
.quizBoxContinueBtn:hover{ background:#255bc4; }
|
||||
|
||||
/* Widget "🏆 Score" — TOUJOURS visible une fois posé (pas de collision/
|
||||
déclencheur, contrairement aux deux widgets ci-dessus), voir
|
||||
|
||||
+91
-4
@@ -113,7 +113,23 @@
|
||||
<div class="modal-content">
|
||||
<div class="box">
|
||||
<h2 class="title is-4">Exporter « {{ game.name }} » en Web/SCORM</h2>
|
||||
<p class="hint mb-4">Construit un paquet SCORM 1.2 (imsmanifest.xml + page web autonome, aucun serveur requis) : importe le .zip obtenu directement dans ton LMS (Moodle...) — le score et le statut de la partie remontent automatiquement.</p>
|
||||
<p class="hint mb-4">Construit un paquet SCORM (imsmanifest.xml + page web autonome, aucun serveur requis) : importe le .zip obtenu directement dans ton LMS (Moodle...) — le score et le statut de la partie remontent automatiquement.</p>
|
||||
<!-- Version SCORM (voir db/games/get_scorm_version.py) : 1.2 par
|
||||
défaut (compatibilité la plus large), 2004 pour séparer
|
||||
complétion et réussite (un "échoué" reste "completed" au lieu
|
||||
de retomber en "incomplete", limite propre à la 1.2). -->
|
||||
<div class="field mb-4">
|
||||
<label class="label is-small">Version SCORM</label>
|
||||
<div class="control">
|
||||
<div class="select is-small">
|
||||
<select id="scormVersionSelect" onchange="window.forgeSaveScormVersion()">
|
||||
<option value="1.2">1.2 (compatibilité la plus large)</option>
|
||||
<option value="2004">2004 (3rd/4th edition — sépare complétion et réussite)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<span class="hint" id="scormVersionSaveStatus"></span>
|
||||
</div>
|
||||
<!-- Suivi xAPI (voir publish/build_scorm_package.py, routes/publish/
|
||||
xapi_settings.py) : greffé sur le SCORM ci-dessus, jamais à sa
|
||||
place — un réglage jamais renseigné (endpoint vide) désactive
|
||||
@@ -143,15 +159,39 @@
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<!-- Seuil de réussite (voir db/games/get_success_threshold.py) :
|
||||
s'applique même sans xAPI configuré, c'est ce qui détermine si
|
||||
le SCORM classique (cmi.core.lesson_status) reporte "passed"
|
||||
ou "failed" plutôt que juste "completed" — indépendant du bloc
|
||||
xAPI ci-dessus, jamais retapé à chaque export non plus. -->
|
||||
<details class="propGroup mb-4" id="scoringSettingsGroup">
|
||||
<summary>Seuil de réussite</summary>
|
||||
<div class="propGroupBody">
|
||||
<p class="help mb-2">% de bonnes réponses au quiz requis pour que la partie soit reportée "Réussie" plutôt que "Échouée" (SCORM et xAPI). Sans question posée, la partie reste juste "Terminée".</p>
|
||||
<div class="field">
|
||||
<label class="label is-small">Seuil (%)</label>
|
||||
<div class="control"><input class="input is-small" type="number" min="0" max="100" step="1" id="successThreshold"></div>
|
||||
</div>
|
||||
<div class="field is-grouped" style="align-items:center;">
|
||||
<div class="control"><button type="button" class="button is-small" onclick="window.forgeSaveScoringSettings()">Enregistrer</button></div>
|
||||
<span class="hint" id="scoringSaveStatus"></span>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<div id="scormProgress" style="display:none;">
|
||||
<p class="hint" id="scormStepText">Préparation du jeu...</p>
|
||||
<div class="passwordStrengthBar" style="margin-top:10px;"><div class="passwordStrengthFill strong" id="scormProgressFill" style="width:100%; animation:forgePublishPulse 1.2s ease-in-out infinite;"></div></div>
|
||||
</div>
|
||||
<div id="scormError" class="notification is-danger" style="display:none;"></div>
|
||||
<div id="scormDone" class="notification is-success" style="display:none;">Téléchargement lancé — importe directement le .zip dans ton LMS.</div>
|
||||
<div class="field mt-4" style="display:flex; justify-content:flex-end; gap:8px;">
|
||||
<button type="button" class="button is-small" onclick="window.forgeCloseScormModal()">Fermer</button>
|
||||
<button type="button" class="button primary is-small" id="scormExportButton" onclick="window.forgeStartScormExport()">Exporter (Web/SCORM)</button>
|
||||
<div class="field mt-4" style="display:flex; justify-content:space-between; align-items:center; gap:8px;">
|
||||
<!-- Déclaration d'accessibilité (voir publish/accessibility_declaration.py)
|
||||
— pas un bouton d'export, un simple lien de téléchargement direct. -->
|
||||
<a href="{{ url_for('accessibility_declaration_get', slug=game.slug) }}" class="hint" style="font-size:12px;">📄 Déclaration d'accessibilité</a>
|
||||
<div style="display:flex; gap:8px;">
|
||||
<button type="button" class="button is-small" onclick="window.forgeCloseScormModal()">Fermer</button>
|
||||
<button type="button" class="button primary is-small" id="scormExportButton" onclick="window.forgeStartScormExport()">Exporter (Web/SCORM)</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -172,6 +212,49 @@
|
||||
var xapiPasswordHint = document.getElementById('xapiPasswordHint');
|
||||
var xapiSaveStatus = document.getElementById('xapiSaveStatus');
|
||||
|
||||
var successThreshold = document.getElementById('successThreshold');
|
||||
var scoringSaveStatus = document.getElementById('scoringSaveStatus');
|
||||
|
||||
var scormVersionSelect = document.getElementById('scormVersionSelect');
|
||||
var scormVersionSaveStatus = document.getElementById('scormVersionSaveStatus');
|
||||
|
||||
function forgeLoadScormVersion() {
|
||||
fetch("{{ url_for('scorm_version_get', slug=game.slug) }}")
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (json) { scormVersionSelect.value = json.scorm_version; });
|
||||
}
|
||||
|
||||
window.forgeSaveScormVersion = function () {
|
||||
scormVersionSaveStatus.textContent = 'Enregistrement...';
|
||||
fetch("{{ url_for('scorm_version_set', slug=game.slug) }}", {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ scorm_version: scormVersionSelect.value }),
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (json) { scormVersionSaveStatus.textContent = json.ok ? 'Enregistré ✓' : 'Échec de l\'enregistrement.'; })
|
||||
.catch(function () { scormVersionSaveStatus.textContent = 'Échec de l\'enregistrement.'; });
|
||||
};
|
||||
|
||||
function forgeLoadScoringSettings() {
|
||||
fetch("{{ url_for('scoring_settings_get', slug=game.slug) }}")
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (json) { successThreshold.value = json.success_threshold_percent; });
|
||||
}
|
||||
|
||||
window.forgeSaveScoringSettings = function () {
|
||||
scoringSaveStatus.textContent = 'Enregistrement...';
|
||||
fetch("{{ url_for('scoring_settings_set', slug=game.slug) }}", {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ success_threshold_percent: parseInt(successThreshold.value, 10) }),
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (json) {
|
||||
scoringSaveStatus.textContent = json.ok ? 'Enregistré ✓' : 'Échec de l\'enregistrement.';
|
||||
if (json.ok) forgeLoadScoringSettings();
|
||||
})
|
||||
.catch(function () { scoringSaveStatus.textContent = 'Échec de l\'enregistrement.'; });
|
||||
};
|
||||
|
||||
function forgeLoadXapiSettings() {
|
||||
fetch("{{ url_for('xapi_settings_get', slug=game.slug) }}")
|
||||
.then(function (r) { return r.json(); })
|
||||
@@ -213,7 +296,11 @@
|
||||
errorBox.style.display = 'none';
|
||||
doneBox.style.display = 'none';
|
||||
xapiSaveStatus.textContent = '';
|
||||
scoringSaveStatus.textContent = '';
|
||||
scormVersionSaveStatus.textContent = '';
|
||||
forgeLoadXapiSettings();
|
||||
forgeLoadScoringSettings();
|
||||
forgeLoadScormVersion();
|
||||
modal.classList.add('is-active');
|
||||
};
|
||||
|
||||
|
||||
+25
-2
@@ -60,6 +60,19 @@
|
||||
50% { box-shadow: 0 0 0 3px rgba(255, 196, 0, 0.15); }
|
||||
}
|
||||
.forgeHighlight{ animation: forgeHighlightPulse 1.1s ease-in-out infinite; }
|
||||
/* RGAA/WCAG 2.3.3 (confort) : un utilisateur sensible aux animations
|
||||
(prefers-reduced-motion, réglage système) ne doit jamais voir un
|
||||
clignotement en boucle infinie — remplacé par un simple contour fixe. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.forgeHighlight{ animation:none; box-shadow:0 0 0 3px rgba(255, 196, 0, 0.9); }
|
||||
}
|
||||
/* RGAA/WCAG 4.1.2 : texte réservé aux lecteurs d'écran, invisible à
|
||||
l'écran mais jamais display:none (qui le retirerait aussi de l'arbre
|
||||
d'accessibilité) — voir templates/play.html, le <h1> caché du titre. */
|
||||
.srOnly{
|
||||
position:absolute; width:1px; height:1px; padding:0; margin:-1px;
|
||||
overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0;
|
||||
}
|
||||
/* 3.5 (Confort) — verrouillage d'un élément après décision : grisé +
|
||||
inerte, posé/enlevé par une action "Modifier un élément → Désactivé". */
|
||||
.forgeDisabled{ opacity:0.45; filter:grayscale(60%); pointer-events:none; }
|
||||
@@ -69,7 +82,8 @@
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="playRoot">
|
||||
<main id="playRoot">
|
||||
<h1 class="srOnly">{{ game.name }}</h1>
|
||||
<div id="playFrame">
|
||||
{% for s in screens_data %}
|
||||
<!-- Un seul moteur d'écran désormais (jeu_2d) : objets déjà entièrement
|
||||
@@ -96,7 +110,7 @@
|
||||
<div id="emptyState">Aucun écran défini pour l'instant — retourne dans l'éditeur pour en créer un.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
// Données injectées par Jinja (côté serveur) — tout le reste du moteur
|
||||
@@ -127,6 +141,15 @@
|
||||
// paquet sans réglage LRS renvoie `null` ici, xapi-client.js ne fait
|
||||
// alors rien.
|
||||
window.FORGE_XAPI_CONFIG = {{ xapi_config_json|safe }};
|
||||
// Seuil de réussite (voir db/games/get_success_threshold.py) : % de
|
||||
// bonnes réponses au quiz requis pour un statut de fin "reussi" plutôt
|
||||
// que "echoue" (SCORM lesson_status ET xAPI, voir dialogue-box-controller.js).
|
||||
window.FORGE_SUCCESS_THRESHOLD_PERCENT = {{ success_threshold_percent|tojson }};
|
||||
// Score max possible du quiz (voir db/quests/reward_budget.py) : permet
|
||||
// au SCORM 1.2 de fournir cmi.core.score.min/max en plus du score brut
|
||||
// (voir static/js/play/offline/scorm-api.js), pour qu'un LMS calcule un
|
||||
// vrai pourcentage au lieu d'afficher le score brut à tort étiqueté "%".
|
||||
window.FORGE_MAX_QUIZ_SCORE = {{ max_quiz_score|tojson }};
|
||||
{% else %}
|
||||
var FORGE_PLAY_URLS = {
|
||||
runtimePayload: {{ runtime_payload_url|tojson }},
|
||||
|
||||
@@ -408,6 +408,12 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- RGAA/WCAG 1.4.3 : ces couleurs sont libres, sans aucune
|
||||
garde-fou avant — un texte blanc sur fond blanc était
|
||||
possible sans le moindre avertissement. Rempli par
|
||||
checkDialogueBoxContrast() (scene-editor.js), appelée à
|
||||
chaque changement de couleur ci-dessus. -->
|
||||
<p class="help" id="dboxContrastWarning" style="color:#e0524d;"></p>
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Déclaration d'accessibilité (voir publish/accessibility_declaration.py,
|
||||
routes/publish/accessibility_declaration.py) — un résumé de ce que le
|
||||
moteur implémente, PAS une certification RGAA complète."""
|
||||
from tests.test_auth import anon_client # noqa: F401
|
||||
|
||||
|
||||
def test_accessibility_declaration_route_returns_a_text_attachment(client, game):
|
||||
resp = client.get(f"/game/{game}/accessibility-declaration")
|
||||
assert resp.status_code == 200
|
||||
assert "text/plain" in resp.headers["Content-Type"]
|
||||
assert "attachment" in resp.headers["Content-Disposition"]
|
||||
|
||||
|
||||
def test_accessibility_declaration_mentions_the_game_name(client, game):
|
||||
import db
|
||||
game_name = db.game_meta(game)["name"]
|
||||
resp = client.get(f"/game/{game}/accessibility-declaration")
|
||||
text = resp.data.decode("utf-8")
|
||||
assert "Déclaration d'accessibilité" in text
|
||||
assert game_name in text
|
||||
|
||||
|
||||
def test_accessibility_declaration_route_is_isolated_like_other_game_routes(anon_client):
|
||||
"""Même garde d'accès que /game/<slug>/publish (core/auth_guard.py) —
|
||||
aucune vérification supplémentaire écrite pour cette route."""
|
||||
from tests.test_auth import _register, _confirm_2fa, _complete_onboarding, _cleanup_project
|
||||
|
||||
_register(anon_client, "a11ydeclarationisolation@example.com")
|
||||
_confirm_2fa(anon_client)
|
||||
_complete_onboarding(anon_client)
|
||||
try:
|
||||
resp = anon_client.get("/game/un-projet-qui-nest-pas-le-sien/accessibility-declaration")
|
||||
assert resp.status_code == 403
|
||||
finally:
|
||||
_cleanup_project("a11ydeclarationisolation@example.com")
|
||||
@@ -83,6 +83,70 @@ def test_export_scorm_index_html_embeds_the_xapi_config_when_set(client, game):
|
||||
assert 'src="static/js/play/offline/xapi-client.js"' in html
|
||||
|
||||
|
||||
def test_export_scorm_index_html_embeds_the_success_threshold(client, game):
|
||||
resp = client.post(f"/game/{game}/export-scorm")
|
||||
import io
|
||||
with zipfile.ZipFile(io.BytesIO(resp.data)) as zf:
|
||||
html = zf.read("index.html").decode("utf-8")
|
||||
assert "window.FORGE_SUCCESS_THRESHOLD_PERCENT = 70;" in html
|
||||
|
||||
db.set_success_threshold(game, 40)
|
||||
resp = client.post(f"/game/{game}/export-scorm")
|
||||
with zipfile.ZipFile(io.BytesIO(resp.data)) as zf:
|
||||
html = zf.read("index.html").decode("utf-8")
|
||||
assert "window.FORGE_SUCCESS_THRESHOLD_PERCENT = 40;" in html
|
||||
|
||||
|
||||
def test_export_scorm_defaults_to_scorm_1_2_manifest_and_wrapper(client, game):
|
||||
resp = client.post(f"/game/{game}/export-scorm")
|
||||
import io
|
||||
with zipfile.ZipFile(io.BytesIO(resp.data)) as zf:
|
||||
manifest = zf.read("imsmanifest.xml").decode("utf-8")
|
||||
html = zf.read("index.html").decode("utf-8")
|
||||
assert "<schemaversion>1.2</schemaversion>" in manifest
|
||||
assert 'adlcp:scormtype="sco"' in manifest
|
||||
assert 'src="static/js/play/offline/scorm-api.js"' in html
|
||||
assert 'src="static/js/play/offline/scorm2004-api.js"' not in html
|
||||
|
||||
|
||||
def test_export_scorm_2004_uses_the_2004_manifest_and_wrapper(client, game):
|
||||
db.set_scorm_version(game, "2004")
|
||||
resp = client.post(f"/game/{game}/export-scorm")
|
||||
import io
|
||||
with zipfile.ZipFile(io.BytesIO(resp.data)) as zf:
|
||||
names = set(zf.namelist())
|
||||
manifest = zf.read("imsmanifest.xml").decode("utf-8")
|
||||
html = zf.read("index.html").decode("utf-8")
|
||||
assert "static/js/play/offline/scorm2004-api.js" in names
|
||||
assert "<schemaversion>2004 4th Edition</schemaversion>" in manifest
|
||||
assert 'adlcp:scormType="sco"' in manifest
|
||||
assert 'src="static/js/play/offline/scorm2004-api.js"' in html
|
||||
assert 'src="static/js/play/offline/scorm-api.js"' not in html
|
||||
|
||||
|
||||
def test_export_scorm_index_html_embeds_the_max_quiz_score(client, game):
|
||||
"""Sans quête/question : max=0 (SCORM 1.2/2004 n'ont alors rien à
|
||||
barémer, voir static/js/play/offline/scorm-api.js)."""
|
||||
resp = client.post(f"/game/{game}/export-scorm")
|
||||
import io
|
||||
with zipfile.ZipFile(io.BytesIO(resp.data)) as zf:
|
||||
html = zf.read("index.html").decode("utf-8")
|
||||
assert "window.FORGE_MAX_QUIZ_SCORE = 0;" in html
|
||||
|
||||
quest = db.create_quest(game, "Une quête")
|
||||
db.set_quest_dialogues(game, quest["id"], {
|
||||
"nouvelle": [], "terminee": [],
|
||||
"en_cours": [
|
||||
{"type": "question", "text": "1+1 ?", "choices": ["1", "2"], "correct_index": 1, "reward_amount": 5},
|
||||
{"type": "question", "text": "2+2 ?", "choices": ["3", "4"], "correct_index": 1, "reward_amount": 7},
|
||||
],
|
||||
})
|
||||
resp = client.post(f"/game/{game}/export-scorm")
|
||||
with zipfile.ZipFile(io.BytesIO(resp.data)) as zf:
|
||||
html = zf.read("index.html").decode("utf-8")
|
||||
assert "window.FORGE_MAX_QUIZ_SCORE = 12;" in html
|
||||
|
||||
|
||||
def test_export_scorm_route_is_isolated_like_other_game_routes(anon_client):
|
||||
"""Même garde d'accès que /game/<slug>/publish (core/auth_guard.py) —
|
||||
aucune vérification supplémentaire écrite pour cette route."""
|
||||
|
||||
@@ -76,6 +76,38 @@ def test_render_scene_object_shows_idle_frame_pixel_positioned(tmp_game_slug_cle
|
||||
assert 'data-personnage="1"' in html
|
||||
|
||||
|
||||
def test_render_scene_object_alt_uses_the_authored_name_rgaa(tmp_game_slug_cleanup):
|
||||
"""RGAA/WCAG 1.1.1 : le nom donné par l'auteur (panneau "ℹ️
|
||||
Informations") sert d'alternative textuelle — jamais aucun attribut
|
||||
alt du tout n'était généré avant."""
|
||||
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
|
||||
obj_id = screens.add_scene_object(slug, screen_id, forge_character="zombie")
|
||||
screens.set_scene_object_name(slug, obj_id, "Zombie du hangar")
|
||||
obj = screens.get_scene_object(slug, obj_id)
|
||||
html = screens.render_scene_object(obj)
|
||||
assert 'alt="Zombie du hangar"' in html
|
||||
|
||||
|
||||
def test_render_scene_object_alt_is_empty_when_unnamed_rgaa(tmp_game_slug_cleanup):
|
||||
"""alt="" (décoratif) plutôt qu'aucun attribut du tout quand l'auteur
|
||||
n'a pas nommé l'objet."""
|
||||
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
|
||||
obj_id = screens.add_scene_object(slug, screen_id, forge_character="zombie")
|
||||
obj = screens.get_scene_object(slug, obj_id)
|
||||
html = screens.render_scene_object(obj)
|
||||
assert 'alt=""' in html
|
||||
|
||||
|
||||
def test_render_scene_object_fond_alt_is_always_empty_rgaa(tmp_game_slug_cleanup):
|
||||
"""Un "fond" est toujours décoratif — alt="" même s'il est nommé."""
|
||||
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
|
||||
obj_id = screens.add_scene_object(slug, screen_id, kind="fond", background_slug="foret")
|
||||
screens.set_scene_object_name(slug, obj_id, "Grand décor de fond")
|
||||
obj = screens.get_scene_object(slug, obj_id)
|
||||
html = screens.render_scene_object(obj)
|
||||
assert 'alt=""' in html
|
||||
|
||||
|
||||
def test_delete_scene_object_cleans_up_referencing_flow_nodes(tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
|
||||
obj_id = screens.add_scene_object(slug, screen_id)
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Seuil de réussite (voir db/games/get_success_threshold.py,
|
||||
routes/publish/scoring_settings.py) — détermine le statut SCORM/xAPI
|
||||
"reussi"/"echoue" en fin de partie, voir static/js/play/
|
||||
dialogue-box-controller.js::forgeComputeFinalScormStatus."""
|
||||
import db
|
||||
from tests.test_auth import anon_client # noqa: F401
|
||||
|
||||
|
||||
def test_scoring_settings_get_defaults_to_seventy_percent(client, game):
|
||||
resp = client.get(f"/game/{game}/scoring-settings")
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json() == {"success_threshold_percent": 70}
|
||||
|
||||
|
||||
def test_scoring_settings_post_then_get_round_trip(client, game):
|
||||
resp = client.post(f"/game/{game}/scoring-settings", json={"success_threshold_percent": 50})
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json() == {"ok": True, "success_threshold_percent": 50}
|
||||
|
||||
resp = client.get(f"/game/{game}/scoring-settings")
|
||||
assert resp.get_json() == {"success_threshold_percent": 50}
|
||||
|
||||
|
||||
def test_scoring_settings_post_clamps_out_of_range_values(client, game):
|
||||
client.post(f"/game/{game}/scoring-settings", json={"success_threshold_percent": 150})
|
||||
assert db.get_success_threshold(game) == 100
|
||||
|
||||
client.post(f"/game/{game}/scoring-settings", json={"success_threshold_percent": -10})
|
||||
assert db.get_success_threshold(game) == 0
|
||||
|
||||
|
||||
def test_scoring_settings_post_rejects_invalid_payload(client, game):
|
||||
resp = client.post(f"/game/{game}/scoring-settings", json={"success_threshold_percent": "abc"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_scoring_settings_route_is_isolated_like_other_game_routes(anon_client):
|
||||
"""Même garde d'accès que /game/<slug>/publish (core/auth_guard.py) —
|
||||
aucune vérification supplémentaire écrite pour cette route."""
|
||||
from tests.test_auth import _register, _confirm_2fa, _complete_onboarding, _cleanup_project
|
||||
|
||||
_register(anon_client, "scoringsettingsisolation@example.com")
|
||||
_confirm_2fa(anon_client)
|
||||
_complete_onboarding(anon_client)
|
||||
try:
|
||||
resp = anon_client.get("/game/un-projet-qui-nest-pas-le-sien/scoring-settings")
|
||||
assert resp.status_code == 403
|
||||
resp = anon_client.post("/game/un-projet-qui-nest-pas-le-sien/scoring-settings", json={})
|
||||
assert resp.status_code == 403
|
||||
finally:
|
||||
_cleanup_project("scoringsettingsisolation@example.com")
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Version SCORM exportée (voir db/games/get_scorm_version.py,
|
||||
routes/publish/scorm_version_settings.py) — '1.2' par défaut, '2004' au
|
||||
choix (sépare completion_status/success_status, voir static/js/play/
|
||||
offline/scorm2004-api.js)."""
|
||||
import db
|
||||
from tests.test_auth import anon_client # noqa: F401
|
||||
|
||||
|
||||
def test_scorm_version_get_defaults_to_one_dot_two(client, game):
|
||||
resp = client.get(f"/game/{game}/scorm-version")
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json() == {"scorm_version": "1.2"}
|
||||
|
||||
|
||||
def test_scorm_version_post_then_get_round_trip(client, game):
|
||||
resp = client.post(f"/game/{game}/scorm-version", json={"scorm_version": "2004"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json() == {"ok": True, "scorm_version": "2004"}
|
||||
|
||||
resp = client.get(f"/game/{game}/scorm-version")
|
||||
assert resp.get_json() == {"scorm_version": "2004"}
|
||||
|
||||
|
||||
def test_scorm_version_post_rejects_invalid_value(client, game):
|
||||
resp = client.post(f"/game/{game}/scorm-version", json={"scorm_version": "3rd-edition"})
|
||||
assert resp.status_code == 400
|
||||
assert db.get_scorm_version(game) == "1.2"
|
||||
|
||||
|
||||
def test_scorm_version_route_is_isolated_like_other_game_routes(anon_client):
|
||||
"""Même garde d'accès que /game/<slug>/publish (core/auth_guard.py) —
|
||||
aucune vérification supplémentaire écrite pour cette route."""
|
||||
from tests.test_auth import _register, _confirm_2fa, _complete_onboarding, _cleanup_project
|
||||
|
||||
_register(anon_client, "scormversionisolation@example.com")
|
||||
_confirm_2fa(anon_client)
|
||||
_complete_onboarding(anon_client)
|
||||
try:
|
||||
resp = anon_client.get("/game/un-projet-qui-nest-pas-le-sien/scorm-version")
|
||||
assert resp.status_code == 403
|
||||
resp = anon_client.post("/game/un-projet-qui-nest-pas-le-sien/scorm-version", json={})
|
||||
assert resp.status_code == 403
|
||||
finally:
|
||||
_cleanup_project("scormversionisolation@example.com")
|
||||
Reference in New Issue
Block a user