Dev #14

Merged
w-vandal merged 2 commits from dev into main 2026-09-07 14:52:22 +00:00
41 changed files with 1985 additions and 64 deletions
+6
View File
@@ -34,6 +34,12 @@ from .games.create_game import create_game
from .games.update_game_name import update_game_name
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,
+19
View File
@@ -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"]
+19
View File
@@ -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"])
+23
View File
@@ -0,0 +1,23 @@
from ..connection import connect
def get_xapi_settings(slug):
"""Réglages xAPI de CE jeu (voir set_xapi_settings.py — stockés dans
_meta, même convention que 'name'/'onboarding_type', voir
db/games/game_type_catalog.py) : URL du LRS (Learning Record Store)
+ identifiants, saisis une seule fois par le créateur du jeu (voir
routes/publish/xapi_settings.py) plutôt que négociés à chaque
lancement (jamais le protocole cmi5 complet, voir publish/
build_scorm_package.py::build_scorm_zip). Chaînes vides si non
configuré — `endpoint` vide signifie "xAPI désactivé pour ce jeu"."""
conn = connect(slug)
rows = conn.execute(
"SELECT key, value FROM _meta WHERE key IN ('xapi_lrs_endpoint', 'xapi_lrs_login', 'xapi_lrs_password')"
).fetchall()
conn.close()
values = {row["key"]: row["value"] for row in rows}
return {
"endpoint": values.get("xapi_lrs_endpoint") or "",
"login": values.get("xapi_lrs_login") or "",
"password": values.get("xapi_lrs_password") or "",
}
+15
View File
@@ -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()
+14
View File
@@ -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
+24
View File
@@ -0,0 +1,24 @@
from ..connection import connect
def set_xapi_settings(slug, endpoint, login, password=None):
"""Enregistre les réglages xAPI de CE jeu (voir get_xapi_settings.py).
`password=None` (champ laissé vide côté formulaire, voir
routes/publish/xapi_settings.py) laisse le mot de passe déjà
enregistré INCHANGÉ — jamais écrasé par une chaîne vide, pour ne pas
obliger à le retaper à chaque modification de l'URL/du login, et pour
que la route GET puisse ne jamais renvoyer sa valeur au navigateur
(juste un booléen "déjà configuré")."""
conn = connect(slug)
conn.execute(
"INSERT OR REPLACE INTO _meta (key, value) VALUES ('xapi_lrs_endpoint', ?)", (endpoint or "",)
)
conn.execute(
"INSERT OR REPLACE INTO _meta (key, value) VALUES ('xapi_lrs_login', ?)", (login or "",)
)
if password is not None:
conn.execute(
"INSERT OR REPLACE INTO _meta (key, value) VALUES ('xapi_lrs_password', ?)", (password,)
)
conn.commit()
conn.close()
+67
View File
@@ -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())
+61 -2
View File
@@ -238,6 +238,57 @@ def build_scorm_zip(slug):
# seule personne à la fois sur son poste, comme /game/<slug>/play —
# jamais d'état "par joueur" à distinguer hors ligne.
payload = screens.full_game_payload(slug)
# xAPI (voir db/games/get_xapi_settings.py, routes/publish/
# xapi_settings.py) : bolt-on volontairement simple, JAMAIS le
# protocole cmi5 complet (pas de négociation de jeton au lancement) —
# les identifiants du LRS sont saisis une fois par le créateur du jeu
# et embarqués tels quels dans le paquet exporté, lu par
# static/js/play/offline/xapi-client.js. `endpoint` vide (réglage
# jamais renseigné) désactive entièrement xAPI pour ce jeu : le
# SCORM classique (scorm-api.js) continue de fonctionner à l'identique
# dans tous les cas.
xapi_settings = db.get_xapi_settings(slug)
xapi_config = None
if xapi_settings["endpoint"]:
xapi_config = {
"endpoint": xapi_settings["endpoint"],
"login": xapi_settings["login"],
"password": xapi_settings["password"],
"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
@@ -252,7 +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"])
@@ -260,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)
+50 -8
View File
@@ -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,
)
+4
View File
@@ -1 +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}"'},
)
+26
View File
@@ -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})
+23
View File
@@ -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})
+33
View File
@@ -0,0 +1,33 @@
from flask import jsonify, request
import db
from core.flask_app import app
@app.route("/game/<slug>/xapi-settings", methods=["GET"])
def xapi_settings_get(slug):
"""Réglages xAPI de ce jeu (voir db/games/get_xapi_settings.py) —
jamais le mot de passe en clair, juste `password_set` (voir la
modale d'export, templates/base.html, qui pré-remplit endpoint/login
mais laisse toujours le champ mot de passe vide)."""
settings = db.get_xapi_settings(slug)
return jsonify({
"endpoint": settings["endpoint"],
"login": settings["login"],
"password_set": bool(settings["password"]),
})
@app.route("/game/<slug>/xapi-settings", methods=["POST"])
def xapi_settings_set(slug):
"""Enregistre les réglages xAPI de ce jeu — voir
db/games/set_xapi_settings.py : un mot de passe absent/vide dans le
corps JSON laisse celui déjà enregistré inchangé plutôt que de
l'effacer."""
body = request.get_json(silent=True) or {}
endpoint = (body.get("endpoint") or "").strip()
login = (body.get("login") or "").strip()
password = body.get("password") or None
db.set_xapi_settings(slug, endpoint, login, password)
return jsonify({"ok": True})
+19 -4
View File
@@ -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; }
});
+75 -19
View File
@@ -40,6 +40,12 @@
var forgeDialogueBoxState = {};
var forgeQuizBoxState = {};
window.forgeQuizScore = window.forgeQuizScore || 0;
// Compte TOUTE réponse (bonne ou mauvaise) au fil de la partie — sert
// uniquement à calculer un taux de réussite global (voir
// forgeSyncAllQuestsCompletionToScorm), distinct de window.forgeQuizScore
// qui ne compte que les points gagnés.
window.forgeQuizTotalAnswered = window.forgeQuizTotalAnswered || 0;
window.forgeQuizCorrectAnswered = window.forgeQuizCorrectAnswered || 0;
function forgeQuestById(questId) {
return (gameData.quests || []).find(function (q) { return q.id === questId; });
@@ -117,6 +123,11 @@ function forgeSyncQuizScoreToScorm() {
gameData.scoring = gameData.scoring || { score: 0, status: 'non_commence' };
gameData.scoring.score = window.forgeQuizScore;
if (gameData.scoring.status === 'non_commence') gameData.scoring.status = 'en_cours';
// xAPI bolt-on (voir static/js/play/offline/xapi-client.js) : ce chemin
// quête/quiz contourne static/js/play/offline/apply-actions.js (aucune
// action de flow "Modifier un score" ici), donc jamais notifié sans cet
// appel direct — même bug que le commentaire ci-dessus pour SCORM.
if (typeof forgeXapiNotifyScoreChanged === "function") forgeXapiNotifyScoreChanged(gameData.scoring.score);
}
// Une quête acceptée = la partie a commencé, même sans le moindre point
@@ -130,11 +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';
var finalStatus = forgeComputeFinalScormStatus();
gameData.scoring.status = finalStatus;
if (typeof forgeXapiNotifyStatusChanged === "function") forgeXapiNotifyStatusChanged(finalStatus);
}
function forgeAddQuizScore(amount) {
@@ -157,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();
@@ -209,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');
@@ -228,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"]');
@@ -291,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) ----------
@@ -327,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];
@@ -348,8 +403,9 @@ if (typeof module !== 'undefined' && module.exports) {
forgeQuestById, forgeShowDialogueBox, forgeDialogueBoxAdvance,
forgeDialogueBoxWidgetOnCurrentScreen, forgeDialogueBoxWidgetById, forgeRenderDialogueBoxLine,
forgeShowQuestOffer, forgeDialogueBoxAcceptQuest, forgeDialogueBoxDeclineQuest, forgeResetDialogueBoxFooter,
forgeShowQuizBox, forgeQuizBoxAnswer, forgeQuizBoxWidgetOnCurrentScreen, forgeQuizBoxWidgetById,
forgeShowQuizBox, forgeQuizBoxAnswer, forgeQuizBoxContinue, forgeQuizBoxWidgetOnCurrentScreen, forgeQuizBoxWidgetById,
forgeUpdateAllScoreWidgets, forgeAddQuizScore, forgeAdvanceConversation, forgeShowCurrentConversationLine,
forgeSyncQuizScoreToScorm, forgeSyncQuestStartedToScorm, forgeSyncAllQuestsCompletionToScorm,
forgeComputeFinalScormStatus,
};
}
@@ -43,6 +43,18 @@ test('image — self-closing, pas de contenu ni enfants', () => {
assert.ok(html.includes('src="chat.png"'));
});
test('image — alt="" par défaut (RGAA 1.1.1, décoratif faute de champ de description dédié)', () => {
const el = { id: 4, widget: 'image', attributes: { src: 'chat.png' }, style: {} };
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
assert.ok(html.includes('alt=""'));
});
test('image — un alt déjà posé par l\'auteur n\'est jamais écrasé', () => {
const el = { id: 4, widget: 'image', attributes: { src: 'chat.png', alt: 'Un chat noir' }, style: {} };
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
assert.ok(html.includes('alt="Un chat noir"'));
});
test('liste — une <li> par ligne de contenu, échappée', () => {
const el = { id: 5, widget: 'liste', content: 'Pomme\nPoire < Kiwi\n\n', attributes: {}, style: {} };
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
@@ -58,6 +58,12 @@ test('icône — repli sur "star" si _icon_slug absent', () => {
assert.ok(html.includes("--icon-url:url(&#x27;icons/star.svg&#x27;)"));
});
test('icône — aria-hidden="true" par défaut (RGAA 1.1.1, décorative faute de description)', () => {
const el = { id: 6, widget: 'icone', attributes: {}, style: {} };
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
assert.ok(html.includes('aria-hidden="true"'));
});
// Régression signalée par un utilisateur (deux fois) : un chemin de
// fichier pour --icon-url, même corrigé pour être relatif à
// static/style.css (là où mask-image:var(--icon-url) le consomme), reste
@@ -89,6 +95,7 @@ test('personnage — lit gameData.personnage_animations (idle en premier)', () =
assert.ok(html.startsWith('<img '));
assert.ok(html.includes('src="/static/characters/zombie/idle0.png"'));
assert.ok(html.includes('data-personnage="1"'));
assert.ok(html.includes('alt=""')); // RGAA 1.1.1, voir render-element.js
});
test('jauge — pourcentage et couleur interpolée depuis un champ d\'objet', () => {
@@ -0,0 +1,76 @@
// Export Web/SCORM — pont SCORM 1.2 (voir ../scorm-api.js). Couvre en
// particulier cmi.core.score.min/max (ajouté pour corriger : "5% pour
// 1.2 mais 100% pour 2004 avec 1 seule question" — sans min/max, le LMS
// n'a que le score BRUT qu'il affiche parfois à tort avec un "%").
const test = require('node:test');
const assert = require('node:assert/strict');
global.window = global.window || {};
global.window.addEventListener = global.window.addEventListener || function () {};
const {
forgeScormFindApi, forgeScormInit, forgeScormSyncScore, FORGE_TO_SCORM_STATUS,
} = require('../scorm-api.js');
function fakeApi() {
const calls = [];
return {
calls,
LMSInitialize: () => calls.push(['LMSInitialize']),
LMSSetValue: (key, value) => calls.push(['LMSSetValue', key, value]),
LMSCommit: () => calls.push(['LMSCommit']),
};
}
test('FORGE_TO_SCORM_STATUS — vocabulaire figé cmi.core.lesson_status', () => {
assert.deepEqual(FORGE_TO_SCORM_STATUS, {
non_commence: 'not attempted', en_cours: 'incomplete', termine: 'completed',
reussi: 'passed', echoue: 'failed',
});
});
test('forgeScormFindApi remonte la chaîne parent jusqu\'à trouver window.API', () => {
const api = {};
const grandparent = { API: api };
const parent = { parent: grandparent };
const win = { parent };
assert.equal(forgeScormFindApi(win, 10), api);
});
test('forgeScormInit ne pose PAS cmi.core.score.min/max si le jeu n\'a aucune question de quiz (max=0)', () => {
const api = fakeApi();
global.window.API = api;
global.window.FORGE_MAX_QUIZ_SCORE = 0;
try {
forgeScormInit();
assert.ok(!api.calls.some(c => c[1] === 'cmi.core.score.max'));
} finally { delete global.window.API; delete global.window.FORGE_MAX_QUIZ_SCORE; }
});
test('forgeScormInit pose cmi.core.score.min/max une fois si le jeu a des questions de quiz', () => {
const api = fakeApi();
global.window.API = api;
global.window.FORGE_MAX_QUIZ_SCORE = 20;
try {
forgeScormInit();
assert.deepEqual(
api.calls.filter(c => c[0] === 'LMSSetValue').map(c => [c[1], c[2]]),
[['cmi.core.score.min', '0'], ['cmi.core.score.max', '20']]
);
} finally { delete global.window.API; delete global.window.FORGE_MAX_QUIZ_SCORE; }
});
test('forgeScormSyncScore envoie score.raw/lesson_status et Commit, une seule fois si rien ne change', () => {
const api = fakeApi();
global.window.API = api;
global.window.FORGE_MAX_QUIZ_SCORE = 0;
try {
forgeScormInit();
forgeScormSyncScore({ scoring: { score: 7, status: 'reussi' } });
const callsAfterFirst = api.calls.length;
assert.ok(api.calls.some(c => c[0] === 'LMSSetValue' && c[1] === 'cmi.core.score.raw' && c[2] === '7'));
assert.ok(api.calls.some(c => c[0] === 'LMSSetValue' && c[1] === 'cmi.core.lesson_status' && c[2] === 'passed'));
forgeScormSyncScore({ scoring: { score: 7, status: 'reussi' } });
assert.equal(api.calls.length, callsAfterFirst);
} finally { delete global.window.API; delete global.window.FORGE_MAX_QUIZ_SCORE; }
});
@@ -0,0 +1,151 @@
// Export Web/SCORM — variante 2004 de scorm-api.js : vérifie surtout que
// "echoue" reste completion_status="completed" (l'apprenant est allé au
// bout) avec success_status="failed", contrairement à la 1.2 où un champ
// unique force "incomplete" dès que le statut est "failed" (voir le bug
// signalé par l'utilisateur).
const test = require('node:test');
const assert = require('node:assert/strict');
global.window = global.window || {};
global.window.addEventListener = global.window.addEventListener || function () {};
const {
forgeScorm2004FindApi, forgeScorm2004Init, forgeScorm2004SyncScore,
forgeScorm2004NotifyQuestionAnswered, forgeScorm2004Timestamp, FORGE_TO_SCORM2004_STATUS,
} = require('../scorm2004-api.js');
function fakeApi(interactionsCount) {
const calls = [];
return {
calls,
Initialize: () => calls.push(['Initialize']),
SetValue: (key, value) => calls.push(['SetValue', key, value]),
GetValue: key => (key === 'cmi.interactions._count' ? String(interactionsCount || 0) : ''),
Commit: () => calls.push(['Commit']),
};
}
test('FORGE_TO_SCORM2004_STATUS — "echoue" reste completion="completed" avec success="failed" (contrairement à la 1.2)', () => {
assert.deepEqual(FORGE_TO_SCORM2004_STATUS.echoue, { completion: 'completed', success: 'failed' });
});
test('FORGE_TO_SCORM2004_STATUS — "reussi" est completion="completed" avec success="passed"', () => {
assert.deepEqual(FORGE_TO_SCORM2004_STATUS.reussi, { completion: 'completed', success: 'passed' });
});
test('FORGE_TO_SCORM2004_STATUS — "termine" (aucune question posée) reste success="unknown"', () => {
assert.deepEqual(FORGE_TO_SCORM2004_STATUS.termine, { completion: 'completed', success: 'unknown' });
});
test('forgeScorm2004FindApi remonte la chaîne parent jusqu\'à trouver API_1484_11', () => {
const api = {};
const grandparent = { API_1484_11: api };
const parent = { parent: grandparent };
const win = { parent };
assert.equal(forgeScorm2004FindApi(win, 10), api);
});
test('forgeScorm2004Init pose cmi.score.min/max une fois si le jeu a des questions de quiz', () => {
const api = fakeApi();
global.window.API_1484_11 = api;
global.window.FORGE_MAX_QUIZ_SCORE = 20;
try {
forgeScorm2004Init();
assert.deepEqual(
api.calls.filter(c => c[0] === 'SetValue').map(c => [c[1], c[2]]),
[['cmi.score.min', '0'], ['cmi.score.max', '20']]
);
} finally { delete global.window.API_1484_11; delete global.window.FORGE_MAX_QUIZ_SCORE; }
});
test('forgeScorm2004SyncScore — "echoue" envoie completion_status="completed" ET success_status="failed" (pas "incomplete")', () => {
const api = fakeApi();
global.window.API_1484_11 = api;
global.window.forgeQuizTotalAnswered = 0;
try {
forgeScorm2004Init();
forgeScorm2004SyncScore({ scoring: { score: 3, status: 'echoue' } });
assert.deepEqual(
api.calls.filter(c => c[0] === 'SetValue').map(c => [c[1], c[2]]),
[['cmi.score.raw', '3'], ['cmi.completion_status', 'completed'], ['cmi.success_status', 'failed']]
);
assert.ok(api.calls.some(c => c[0] === 'Commit'));
} finally { delete global.window.API_1484_11; }
});
test('forgeScorm2004SyncScore — n\'envoie rien de nouveau si score/statut n\'ont pas changé (évite de spammer Commit)', () => {
const api = fakeApi();
global.window.API_1484_11 = api;
// Score/statut distincts des autres tests : forgeScorm2004LastSync est
// un état de module partagé entre tous les tests de ce fichier (comme
// en production, où il ne doit survivre qu'à UNE seule partie).
try {
forgeScorm2004Init();
forgeScorm2004SyncScore({ scoring: { score: 9, status: 'en_cours' } });
const callsAfterFirst = api.calls.length;
assert.ok(callsAfterFirst > 0);
forgeScorm2004SyncScore({ scoring: { score: 9, status: 'en_cours' } });
assert.equal(api.calls.length, callsAfterFirst);
} finally { delete global.window.API_1484_11; }
});
test('forgeScorm2004SyncScore — ajoute cmi.score.scaled UNIQUEMENT si des questions ont été répondues', () => {
const api = fakeApi();
global.window.API_1484_11 = api;
global.window.forgeQuizTotalAnswered = 4;
global.window.forgeQuizCorrectAnswered = 3;
try {
forgeScorm2004Init();
forgeScorm2004SyncScore({ scoring: { score: 3, status: 'reussi' } });
const scaledCall = api.calls.find(c => c[0] === 'SetValue' && c[1] === 'cmi.score.scaled');
assert.equal(scaledCall[2], String(3 / 4));
} finally {
delete global.window.API_1484_11;
delete global.window.forgeQuizTotalAnswered;
delete global.window.forgeQuizCorrectAnswered;
}
});
test('forgeScorm2004Timestamp — pas de millisecondes ni de "Z" (rejetés par certains LMS, voir capture SCORM Cloud avec timestamp vide)', () => {
const ts = forgeScorm2004Timestamp();
assert.match(ts, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/);
});
test('forgeScorm2004NotifyQuestionAnswered pose une entrée cmi.interactions.n.* complète (bonne réponse)', () => {
const api = fakeApi(2); // 2 interactions déjà enregistrées -> prochain indice = 2
global.window.API_1484_11 = api;
try {
forgeScorm2004Init();
forgeScorm2004NotifyQuestionAnswered({ id: 5, title: 'La quête' }, 1, 'Qui es-tu ?', true, 0, 0);
const sets = Object.fromEntries(api.calls.filter(c => c[0] === 'SetValue').map(c => [c[1], c[2]]));
assert.equal(sets['cmi.interactions.2.id'], 'quest:5:question:1');
assert.equal(sets['cmi.interactions.2.type'], 'choice');
assert.match(sets['cmi.interactions.2.timestamp'], /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/);
assert.equal(sets['cmi.interactions.2.description'], '{fr-FR}Qui es-tu ?');
assert.equal(sets['cmi.interactions.2.learner_response'], '0');
assert.equal(sets['cmi.interactions.2.result'], 'correct');
assert.equal(sets['cmi.interactions.2.correct_responses.0.pattern'], '0');
assert.ok(api.calls.some(c => c[0] === 'Commit'));
} finally { delete global.window.API_1484_11; }
});
test('forgeScorm2004NotifyQuestionAnswered — mauvaise réponse : result="incorrect", learner_response ≠ correct_responses', () => {
const api = fakeApi(0);
global.window.API_1484_11 = api;
try {
forgeScorm2004Init();
forgeScorm2004NotifyQuestionAnswered({ id: 5, title: 'La quête' }, 0, 'Qui es-tu ?', false, 1, 0);
const sets = Object.fromEntries(api.calls.filter(c => c[0] === 'SetValue').map(c => [c[1], c[2]]));
assert.equal(sets['cmi.interactions.0.result'], 'incorrect');
assert.equal(sets['cmi.interactions.0.learner_response'], '1');
assert.equal(sets['cmi.interactions.0.correct_responses.0.pattern'], '0');
} finally { delete global.window.API_1484_11; }
});
test('forgeScorm2004NotifyQuestionAnswered ne fait rien hors LMS (forgeScorm2004Api jamais résolu)', () => {
delete global.window.API_1484_11;
forgeScorm2004Init(); // aucune API trouvée -> remet forgeScorm2004Api à null
// Si le repli silencieux (if (!forgeScorm2004Api) return;) était absent,
// ceci lèverait en tentant d'appeler .GetValue sur null.
assert.doesNotThrow(() => forgeScorm2004NotifyQuestionAnswered({ id: 1 }, 0, 'x', true, 0, 0));
});
@@ -0,0 +1,93 @@
// Export Web/SCORM — bolt-on xAPI (voir ../xapi-client.js) : vérifie la
// granularité par quête/question demandée par l'utilisateur (quête
// acceptée/terminée, réponse à une question), en plus du score/statut
// global déjà couvert par dialogue-box-controller.test.js.
const test = require('node:test');
const assert = require('node:assert/strict');
global.window = global.window || {};
global.window.addEventListener = global.window.addEventListener || function () {};
global.btoa = global.btoa || (str => Buffer.from(str).toString('base64'));
const {
forgeXapiNotifyQuestAccepted, forgeXapiNotifyQuestCompleted, forgeXapiNotifyQuestionAnswered,
forgeXapiQuestObject, forgeXapiQuestionObject,
} = require('../xapi-client.js');
test.beforeEach(() => {
global.window.FORGE_XAPI_CONFIG = {
endpoint: 'https://lrs.example.com/xapi', login: 'user', password: 'pass',
activity_id: 'urn:forge-engine:game:test', activity_name: 'Mon jeu',
};
});
test('forgeXapiQuestObject construit un id de sous-activité dérivé de l\'activité du jeu', () => {
const obj = forgeXapiQuestObject({ id: 5, title: 'La quête du sorcier' });
assert.equal(obj.id, 'urn:forge-engine:game:test:quest:5');
assert.equal(obj.definition.name['fr-FR'], 'La quête du sorcier');
});
test('forgeXapiQuestionObject construit un id dérivé incluant la quête ET l\'index de question', () => {
const obj = forgeXapiQuestionObject({ id: 5, title: 'La quête du sorcier' }, 2, 'Qui es-tu ?');
assert.equal(obj.id, 'urn:forge-engine:game:test:quest:5:question:2');
assert.equal(obj.definition.description['fr-FR'], 'Qui es-tu ?');
});
test('forgeXapiNotifyQuestAccepted envoie un statement "attempted" ciblant la quête', () => {
const calls = [];
global.fetch = (url, opts) => { calls.push({ url, opts }); return Promise.resolve(); };
forgeXapiNotifyQuestAccepted({ id: 5, title: 'La quête du sorcier' });
assert.equal(calls.length, 1);
const body = JSON.parse(calls[0].opts.body);
assert.equal(body.verb.id, 'http://adlnet.gov/expapi/verbs/attempted');
assert.equal(body.object.id, 'urn:forge-engine:game:test:quest:5');
});
test('forgeXapiNotifyQuestCompleted envoie un statement "completed" ciblant la quête (result.completion=true)', () => {
const calls = [];
global.fetch = (url, opts) => { calls.push({ url, opts }); return Promise.resolve(); };
forgeXapiNotifyQuestCompleted({ id: 5, title: 'La quête du sorcier' });
const body = JSON.parse(calls[0].opts.body);
assert.equal(body.verb.id, 'http://adlnet.gov/expapi/verbs/completed');
assert.equal(body.object.id, 'urn:forge-engine:game:test:quest:5');
assert.equal(body.result.completion, true);
});
test('forgeXapiNotifyQuestionAnswered envoie un statement "answered" avec success/score, ciblant la question', () => {
const calls = [];
global.fetch = (url, opts) => { calls.push({ url, opts }); return Promise.resolve(); };
forgeXapiNotifyQuestionAnswered({ id: 5, title: 'La quête du sorcier' }, 1, 'Qui es-tu ?', true, 5);
const body = JSON.parse(calls[0].opts.body);
assert.equal(body.verb.id, 'http://adlnet.gov/expapi/verbs/answered');
assert.equal(body.object.id, 'urn:forge-engine:game:test:quest:5:question:1');
assert.equal(body.result.success, true);
assert.equal(body.result.score.raw, 5);
});
test('une mauvaise réponse envoie quand même un statement, avec success=false et score=0', () => {
const calls = [];
global.fetch = (url, opts) => { calls.push({ url, opts }); return Promise.resolve(); };
forgeXapiNotifyQuestionAnswered({ id: 5, title: 'La quête du sorcier' }, 0, 'Qui es-tu ?', false, 0);
const body = JSON.parse(calls[0].opts.body);
assert.equal(body.result.success, false);
assert.equal(body.result.score.raw, 0);
});
test('le libellé du verbe est fourni en anglais ET en français (LRS libre d\'afficher l\'un ou l\'autre)', () => {
const calls = [];
global.fetch = (url, opts) => { calls.push({ url, opts }); return Promise.resolve(); };
forgeXapiNotifyQuestAccepted({ id: 5, title: 'La quête du sorcier' });
const body = JSON.parse(calls[0].opts.body);
assert.equal(body.verb.display['en-US'], 'attempted');
assert.equal(body.verb.display['fr-FR'], 'commencé');
});
test('aucun envoi si FORGE_XAPI_CONFIG est null (xAPI non configuré pour ce jeu)', () => {
global.window.FORGE_XAPI_CONFIG = null;
let called = false;
global.fetch = () => { called = true; return Promise.resolve(); };
forgeXapiNotifyQuestAccepted({ id: 5, title: 'x' });
forgeXapiNotifyQuestCompleted({ id: 5, title: 'x' });
forgeXapiNotifyQuestionAnswered({ id: 5, title: 'x' }, 0, 'x', false, 0);
assert.equal(called, false);
});
+5
View File
@@ -62,6 +62,10 @@ function forgeApplyScoreActionOffline(gameData, action) {
return false;
}
gameData.scoring.score = newValue;
// xAPI bolt-on (voir static/js/play/offline/xapi-client.js) : no-op si
// le fichier n'est pas chargé (tests Node via module.exports ci-dessous,
// ou réglage xAPI jamais configuré pour ce jeu).
if (typeof forgeXapiNotifyScoreChanged === "function") forgeXapiNotifyScoreChanged(newValue);
return true;
}
@@ -70,6 +74,7 @@ function forgeApplyStatusActionOffline(gameData, action) {
if (!status || FORGE_SCORE_STATUS_KEYS.indexOf(status) === -1) return false;
if (!gameData.scoring) gameData.scoring = { score: 0, status: "non_commence" };
gameData.scoring.status = status;
if (typeof forgeXapiNotifyStatusChanged === "function") forgeXapiNotifyStatusChanged(status);
return true;
}
+14 -2
View File
@@ -114,12 +114,17 @@ function forgeStyleString(el, parentFlexDirection, widgetKey) {
return baseStyle + (userStyle ? " " + userStyle + ";" : "");
}
// Port de attr_string.py.
// Port de attr_string.py. "alt" est le seul attribut dont la chaîne VIDE
// a un sens propre (RGAA/WCAG 1.1.1 : alt="" marque explicitement une
// image comme décorative, ce que "pas d'alt du tout" ne fait pas pour un
// lecteur d'écran) — tous les autres attributs vides sont omis comme
// avant (une valeur vide y équivaut à "non réglé").
function forgeAttrString(attrs, extraStyle) {
var parts = ['style="' + forgeHtmlEscape(extraStyle) + '"'];
Object.keys(attrs).forEach(function (k) {
var v = attrs[k];
if (v === null || v === undefined || v === "" || v === false) return;
if (v === null || v === undefined || v === false) return;
if (v === "" && k !== "alt") return;
parts.push(v === true ? forgeHtmlEscape(k) : forgeHtmlEscape(k) + '="' + forgeHtmlEscape(v) + '"');
});
return parts.join(" ");
@@ -275,6 +280,13 @@ function forgeRenderElementHtml(el, gameData, childrenMap, ctx, parentFlexDirect
var hoverText = attrsRaw._survol_texte;
if (hoverText) attrs["data-hover-text"] = forgeApplyCtx(hoverText, ctx);
// alt (RGAA/WCAG 1.1.1) : le widget "image" n'a pas de champ de
// description dédié dans l'éditeur — alt="" (décoratif) par défaut
// plutôt qu'aucun attribut du tout, jamais le nom de fichier brut par
// défaut du navigateur. Ne touche pas à un alt déjà posé (pass-through
// via forgeVisibleAttrs, si l'auteur en règle un un jour).
if (tag === "img" && !("alt" in attrs)) attrs.alt = "";
if (meta.selfClosing) {
return mark("<" + tag + " " + forgeAttrString(attrs, style) + ">");
}
@@ -86,6 +86,11 @@ function forgeRenderIcone(el, meta, gameData, childrenMap, ctx, parentFlexDirect
// réutilisable) — au moins un lien exploitable plutôt qu'un vide.
var iconUrl = (gameData.icon_data_uris && gameData.icon_data_uris[iconSlug]) || ("icons/" + iconSlug + ".svg");
var style = forgeStyleString(el, parentFlexDirection, el.widget) + " --icon-url:url('" + iconUrl + "');";
// aria-hidden (RGAA/WCAG 1.1.1) : une icône seule (pas de champ de
// description dédié dans l'éditeur) est traitée comme purement
// décorative — jamais annoncée par un lecteur d'écran, comme le nom du
// slug FontAwesome ("star") n'aurait aucun sens lu à voix haute.
if (!("aria-hidden" in attrs)) attrs["aria-hidden"] = "true";
return "<i " + forgeAttrString(attrs, style) + "></i>";
}
@@ -137,6 +142,9 @@ function forgeRenderPersonnage(el, meta, gameData, childrenMap, ctx, parentFlexD
var frames = animations ? (animations.idle || animations[Object.keys(animations)[0]]) : null;
var idleFrame = frames && frames.length ? frames[0] : "";
if (idleFrame) attrs.src = idleFrame;
// alt (RGAA/WCAG 1.1.1) : voir render-element.js pour le même défaut
// décoratif sur le widget "image" générique.
if (!("alt" in attrs)) attrs.alt = "";
var style = forgeStyleString(el, parentFlexDirection, el.widget);
return "<img " + forgeAttrString(attrs, style) + ">";
}
+19 -2
View File
@@ -23,6 +23,17 @@ function forgeScormInit() {
forgeScormApi = forgeScormFindApi(window, 10);
if (!forgeScormApi) return; // ouvert hors LMS (test local) : dégrade en silence
forgeScormApi.LMSInitialize("");
// cmi.core.score.min/max (voir publish/build_scorm_package.py,
// db/quests/reward_budget.py) : sans eux, le LMS n'a que le score BRUT
// (cmi.core.score.raw, un total de points sans barème) et l'affiche
// parfois à tort avec un "%" — en fournissant aussi min/max une seule
// fois ici, le LMS peut calculer raw/max lui-même, un vrai pourcentage.
// Omis si le jeu n'a aucune question de quiz (max=0, rien à barémer).
var maxScore = typeof window !== "undefined" ? window.FORGE_MAX_QUIZ_SCORE : 0;
if (maxScore) {
forgeScormApi.LMSSetValue("cmi.core.score.min", "0");
forgeScormApi.LMSSetValue("cmi.core.score.max", String(maxScore));
}
}
// Statut Forge (voir db/constants.py, SCORM_STATUS_KEYS) -> vocabulaire
@@ -56,11 +67,17 @@ if (typeof window !== "undefined") {
// gameData est déjà en mémoire (window.FORGE_OFFLINE) — pas de fetch à
// attendre, un intervalle court suffit à refléter score/statut sans
// devoir faire dépendre bindings.js de ce fichier optionnel.
setInterval(function () {
var forgeScormSyncTimer = setInterval(function () {
if (typeof gameData !== "undefined") forgeScormSyncScore(gameData);
}, 2000);
// .unref() n'existe pas sur la valeur renvoyée par setInterval() d'un
// navigateur (juste un nombre) — seulement sous Node (static/js/play/
// offline/__tests__/) : sans ça, `node --test` ne se termine jamais.
if (forgeScormSyncTimer && typeof forgeScormSyncTimer.unref === "function") {
forgeScormSyncTimer.unref();
}
}
if (typeof module !== "undefined" && module.exports) {
module.exports = { forgeScormFindApi, forgeScormSyncScore, FORGE_TO_SCORM_STATUS };
module.exports = { forgeScormFindApi, forgeScormInit, forgeScormSyncScore, forgeScormFinish, FORGE_TO_SCORM_STATUS };
}
+136
View File
@@ -0,0 +1,136 @@
// Export Web/SCORM — variante SCORM 2004 (3rd/4th edition) de
// scorm-api.js : voir db/games/get_scorm_version.py, publish/
// scorm_manifest.py. Chargé À LA PLACE de scorm-api.js (jamais les deux
// à la fois, voir publish/build_scorm_package.py) quand le créateur du
// jeu choisit "2004" plutôt que "1.2" dans la modale d'export.
//
// Pourquoi une 2ᵉ version : SCORM 1.2 n'a qu'un seul champ
// (cmi.core.lesson_status) pour coder à la fois complétion ET réussite
// — "failed" y implique "incomplete" dans l'interprétation de la
// plupart des LMS (voir FORGE_TO_SCORM_STATUS, scorm-api.js). La 2004
// sépare cmi.completion_status ("completed"/"incomplete") et
// cmi.success_status ("passed"/"failed"/"unknown") : un apprenant qui va
// au bout de la formation mais échoue au seuil de réussite (voir
// db/games/get_success_threshold.py) est enfin reporté "completed" ET
// "failed", au lieu de rester à tort "incomplete".
// L'API 2004 vit dans une fenêtre ancêtre sous le nom API_1484_11 (pas
// "API", réservé à la 1.2) — même algorithme de remontée que SCORM 1.2.
function forgeScorm2004FindApi(win, triesLeft) {
if (!win || triesLeft <= 0) return null;
if (win.API_1484_11) return win.API_1484_11;
if (win.parent && win.parent !== win) return forgeScorm2004FindApi(win.parent, triesLeft - 1);
if (win.opener) return forgeScorm2004FindApi(win.opener, triesLeft - 1);
return null;
}
var forgeScorm2004Api = null;
var forgeScorm2004LastSync = null; // évite de spammer Commit() si rien n'a changé
function forgeScorm2004Init() {
forgeScorm2004Api = forgeScorm2004FindApi(window, 10);
if (!forgeScorm2004Api) return; // ouvert hors LMS (test local) : dégrade en silence
forgeScorm2004Api.Initialize("");
// cmi.score.min/max (voir static/js/play/offline/scorm-api.js pour le
// même réglage côté 1.2) : cmi.score.scaled (forgeScorm2004SyncScore)
// couvre déjà le pourcentage correctement, mais min/max reste utile
// pour tout LMS qui affiche plutôt raw/max.
var maxScore = typeof window !== "undefined" ? window.FORGE_MAX_QUIZ_SCORE : 0;
if (maxScore) {
forgeScorm2004Api.SetValue("cmi.score.min", "0");
forgeScorm2004Api.SetValue("cmi.score.max", String(maxScore));
}
}
// Statut Forge (voir db/constants.py, SCORM_STATUS_KEYS) -> couple
// (completion_status, success_status) SCORM 2004 — contrairement à la
// 1.2, "echoue" reste "completed" (l'apprenant a fini la formation) avec
// success_status="failed", au lieu de retomber en "incomplete".
var FORGE_TO_SCORM2004_STATUS = {
non_commence: { completion: "not attempted", success: "unknown" },
en_cours: { completion: "incomplete", success: "unknown" },
termine: { completion: "completed", success: "unknown" },
reussi: { completion: "completed", success: "passed" },
echoue: { completion: "completed", success: "failed" },
};
function forgeScorm2004SyncScore(gameData) {
if (!forgeScorm2004Api || !gameData || !gameData.scoring) return;
var score = gameData.scoring.score || 0;
var statuses = FORGE_TO_SCORM2004_STATUS[gameData.scoring.status] || FORGE_TO_SCORM2004_STATUS.en_cours;
var key = score + "|" + statuses.completion + "|" + statuses.success;
if (key === forgeScorm2004LastSync) return;
forgeScorm2004LastSync = key;
forgeScorm2004Api.SetValue("cmi.score.raw", String(score));
// cmi.score.scaled (-1..1, ici toujours 0..1) : uniquement dérivable du
// taux de bonnes réponses au quiz (voir dialogue-box-controller.js),
// jamais de gameData.scoring.score qui n'est qu'un total de points sans
// maximum connu — omis si aucune question n'a encore été répondue.
if (typeof window !== "undefined" && window.forgeQuizTotalAnswered) {
var scaled = window.forgeQuizCorrectAnswered / window.forgeQuizTotalAnswered;
forgeScorm2004Api.SetValue("cmi.score.scaled", String(scaled));
}
forgeScorm2004Api.SetValue("cmi.completion_status", statuses.completion);
forgeScorm2004Api.SetValue("cmi.success_status", statuses.success);
forgeScorm2004Api.Commit("");
}
// cmi.interactions.n.* (voir dialogue-box-controller.js::forgeQuizBoxAnswer,
// appelée au même endroit que forgeXapiNotifyQuestionAnswered) : le
// pendant SCORM natif de "answered" xAPI, pour un LMS qui affiche le
// détail question par question dans SA PROPRE interface sans avoir
// besoin d'un LRS xAPI séparé. N'existe qu'en 2004 — SCORM 1.2 n'a pas
// de modèle de données cmi.interactions.*. id/type/timestamp/description/
// learner_response/result/correct_responses.0.pattern : les champs
// minimaux du modèle "choice" (ADL RTE), un index de LMS lit
// cmi.interactions._count pour connaître le prochain indice libre.
// cmi.interactions.n.timestamp (type CMITimestamp, ADL RTE) : PAS de
// millisecondes ni de suffixe "Z" acceptés par tous les LMS — new Date()
// .toISOString() produit "…T19:49:39.923Z", que certains rejettent
// silencieusement (SetValue échoue sans erreur visible : le champ reste
// vide). "…T19:49:39" (sans les millisecondes ni le "Z") passe partout.
function forgeScorm2004Timestamp() {
return new Date().toISOString().replace(/\.\d+Z$/, "");
}
function forgeScorm2004NotifyQuestionAnswered(quest, questionIndex, questionText, correct, choiceIndex, correctIndex) {
if (!forgeScorm2004Api) return;
var n = parseInt(forgeScorm2004Api.GetValue("cmi.interactions._count"), 10);
if (isNaN(n)) n = 0;
var prefix = "cmi.interactions." + n + ".";
forgeScorm2004Api.SetValue(prefix + "id", "quest:" + quest.id + ":question:" + questionIndex);
forgeScorm2004Api.SetValue(prefix + "type", "choice");
forgeScorm2004Api.SetValue(prefix + "timestamp", forgeScorm2004Timestamp());
forgeScorm2004Api.SetValue(prefix + "description", "{fr-FR}" + (questionText || ""));
forgeScorm2004Api.SetValue(prefix + "learner_response", String(choiceIndex));
forgeScorm2004Api.SetValue(prefix + "result", correct ? "correct" : "incorrect");
forgeScorm2004Api.SetValue(prefix + "correct_responses.0.pattern", String(correctIndex));
forgeScorm2004Api.Commit("");
}
function forgeScorm2004Finish() {
if (!forgeScorm2004Api) return;
forgeScorm2004Api.Terminate("");
}
if (typeof window !== "undefined") {
window.addEventListener("load", forgeScorm2004Init);
window.addEventListener("beforeunload", forgeScorm2004Finish);
var forgeScorm2004SyncTimer = setInterval(function () {
if (typeof gameData !== "undefined") forgeScorm2004SyncScore(gameData);
}, 2000);
// .unref() n'existe pas sur la valeur renvoyée par setInterval() d'un
// navigateur (juste un nombre) — seulement sous Node (static/js/play/
// offline/__tests__/) : sans ça, `node --test` ne se termine jamais
// (l'intervalle garde le process vivant indéfiniment).
if (forgeScorm2004SyncTimer && typeof forgeScorm2004SyncTimer.unref === "function") {
forgeScorm2004SyncTimer.unref();
}
}
if (typeof module !== "undefined" && module.exports) {
module.exports = {
forgeScorm2004FindApi, forgeScorm2004Init, forgeScorm2004SyncScore, forgeScorm2004Finish,
forgeScorm2004NotifyQuestionAnswered, forgeScorm2004Timestamp, FORGE_TO_SCORM2004_STATUS,
};
}
+209
View File
@@ -0,0 +1,209 @@
// Export Web/SCORM — bolt-on xAPI (voir publish/build_scorm_package.py,
// db/games/get_xapi_settings.py) : EN PLUS du SCORM classique
// (scorm-api.js, jamais touché par ce fichier), envoie des statements
// xAPI directement au LRS configuré par le créateur du jeu. Chargé
// TOUJOURS dans le paquet exporté (même sans réglage xAPI — voir
// templates/play.html), mais ne fait RIEN tant que
// window.FORGE_XAPI_CONFIG est `null` (réglage jamais renseigné).
//
// Volontairement PAS le protocole cmi5 (pas de négociation de jeton au
// lancement, pas de découverte d'endpoint via l'URL du LMS) : l'URL du
// LRS et les identifiants sont saisis une fois pour toutes par le
// créateur (voir routes/publish/xapi_settings.py) et embarqués tels
// quels dans le paquet — un LRS quelconque suffit, jamais besoin que le
// LMS du client supporte cmi5 lui-même.
//
// Best-effort partout : un LRS injoignable/mal configuré ne doit JAMAIS
// bloquer ni ralentir la partie du joueur.
// Statut Forge -> verbe xAPI ADL standard, uniquement pour les statuts
// qui marquent une VRAIE fin de partie — "non_commence"/"en_cours" ne
// 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" },
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
// Identité de l'apprenant : réutilise l'API SCORM déjà trouvée par
// scorm-api.js (window.forgeScormApi, posée sur "load" — voir plus bas,
// notre propre handler "load" s'enregistre APRÈS le sien, donc after lui)
// si le paquet tourne dans un LMS ; sinon, un acteur anonyme générique.
function forgeXapiActor() {
try {
if (typeof forgeScormApi !== "undefined" && forgeScormApi) {
var name = forgeScormApi.LMSGetValue("cmi.core.student_name");
var id = forgeScormApi.LMSGetValue("cmi.core.student_id");
if (id) {
return { objectType: "Agent", name: name || id, account: { homePage: "urn:forge-engine", name: id } };
}
}
} catch (e) { /* API SCORM présente mais qui répond mal — repli silencieux */ }
return { objectType: "Agent", name: "Apprenant", mbox: "mailto:anonymous@forge-engine.local" };
}
function forgeXapiObject() {
var config = window.FORGE_XAPI_CONFIG;
return {
objectType: "Activity",
id: config.activity_id,
definition: { name: { "fr-FR": config.activity_name } },
};
}
// 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: FORGE_XAPI_VERB_DISPLAY[verbId] || { "en-US": verbId } },
object: object || forgeXapiObject(),
timestamp: new Date().toISOString(),
};
if (result) statement.result = result;
var endpoint = config.endpoint.replace(/\/+$/, "") + "/statements";
try {
fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Experience-API-Version": "1.0.3",
"Authorization": "Basic " + btoa(config.login + ":" + config.password),
},
body: JSON.stringify(statement),
}).catch(function (err) {
console.warn("xAPI: envoi du statement échoué (LRS injoignable ?)", err);
});
} catch (err) {
console.warn("xAPI: impossible d'envoyer le statement", err);
}
}
// Rappelées directement par static/js/play/offline/apply-actions.js après
// chaque mutation RÉELLE (jamais un sondage périodique, contrairement à
// scorm-api.js::forgeScormSyncScore) — un seul statement par changement.
function forgeXapiNotifyScoreChanged(newScore) {
if (!window.FORGE_XAPI_CONFIG) return;
forgeXapiSendStatement(
"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, {
completion: true,
success: newStatus !== "echoue",
});
}
if (typeof window !== "undefined") {
window.addEventListener("load", function () {
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");
});
}
if (typeof module !== "undefined" && module.exports) {
module.exports = {
forgeXapiSendStatement, forgeXapiNotifyScoreChanged, forgeXapiNotifyStatusChanged, FORGE_TO_XAPI_VERB,
forgeXapiNotifyQuestAccepted, forgeXapiNotifyQuestCompleted, forgeXapiNotifyQuestionAnswered,
forgeXapiQuestObject, forgeXapiQuestionObject, FORGE_XAPI_VERB_DISPLAY,
};
}
+36 -3
View File
@@ -96,6 +96,13 @@ function bindHoverTexts() {
});
}
// RGAA/WCAG 2.1.1 (clavier) : un objet "Au clic" n'était auparavant
// actionnable qu'à la souris (simple <div>/<img>, jamais un <button>/<a>
// natif — voir screens/scenes/render_scene_object.py) — inatteignable et
// inactivable au clavier. tabindex="0" + role="button" le rendent
// focusable (sauf s'il l'est déjà — un <button>/<a> auteur, ex. widget
// "bouton"/"lien", ne doit pas se voir écraser son rôle natif) ; Entrée/
// Espace déclenchent le même comportement que le clic.
function bindClicks() {
document.querySelectorAll('[data-element-id]').forEach(elDiv => {
const elementId = parseInt(elDiv.dataset.elementId, 10);
@@ -112,7 +119,12 @@ function bindClicks() {
if (elDiv.dataset.clickBound) return;
elDiv.dataset.clickBound = '1';
elDiv.style.cursor = 'pointer';
elDiv.addEventListener('click', (ev) => {
if (!elDiv.hasAttribute('tabindex')) elDiv.setAttribute('tabindex', '0');
const tag = elDiv.tagName;
if (!elDiv.hasAttribute('role') && tag !== 'BUTTON' && tag !== 'A' && tag !== 'INPUT') {
elDiv.setAttribute('role', 'button');
}
const activate = (ev) => {
ev.stopPropagation();
// Si l'élément cliqué (ou un de ses parents, jusqu'au Répéteur
// qui reçoit le gestionnaire) est en fait une LIGNE de Répéteur,
@@ -126,6 +138,12 @@ function bindClicks() {
// Exécute CHAQUE déclencheur "Au clic" posé sur cet élément, pas
// seulement le premier (voir le commentaire de findTriggerNodess).
triggers.forEach(trigger => runFlowFrom(trigger.node.id, trigger.screenId));
};
elDiv.addEventListener('click', activate);
elDiv.addEventListener('keydown', (ev) => {
if (ev.key !== 'Enter' && ev.key !== ' ' && ev.key !== 'Spacebar') return;
ev.preventDefault();
activate(ev);
});
}
});
@@ -138,6 +156,12 @@ function bindClicks() {
// (voir le commentaire sur TRIGGER_EVENTS dans screens/flow/constants.py
// — pas de retour automatique à l'état d'avant survol, un créateur qui en
// veut un doit poser l'action inverse sur "Fin du survol" lui-même).
// RGAA/WCAG 2.1.1 (clavier) : "Au survol"/"Fin du survol" ne réagissaient
// qu'à la souris (mouseenter/mouseleave) — focus/blur est l'équivalent
// clavier standard (recevoir le focus = "survoler", le perdre = "fin du
// survol"), donc un élément SEULEMENT concerné par le survol (jamais par
// bindClicks() ci-dessus) doit aussi devenir focusable pour rester
// atteignable au clavier.
function bindHoverTriggers() {
document.querySelectorAll('[data-element-id]').forEach(elDiv => {
if (elDiv.dataset.hoverTriggerBound) return;
@@ -146,11 +170,20 @@ function bindHoverTriggers() {
const leaveTriggers = findTriggerNodess(elementId, 'fin_survol');
if (!enterTriggers.length && !leaveTriggers.length) return;
elDiv.dataset.hoverTriggerBound = '1';
if (!elDiv.hasAttribute('tabindex')) elDiv.setAttribute('tabindex', '0');
// Exécute CHAQUE déclencheur trouvé, pas seulement le premier — voir
// le commentaire de findTriggerNodess (plusieurs déclencheurs
// indépendants sur le même élément/événement doivent tous s'exécuter).
if (enterTriggers.length) elDiv.addEventListener('mouseenter', () => enterTriggers.forEach(t => runFlowFrom(t.node.id, t.screenId)));
if (leaveTriggers.length) elDiv.addEventListener('mouseleave', () => leaveTriggers.forEach(t => runFlowFrom(t.node.id, t.screenId)));
if (enterTriggers.length) {
const onEnter = () => enterTriggers.forEach(t => runFlowFrom(t.node.id, t.screenId));
elDiv.addEventListener('mouseenter', onEnter);
elDiv.addEventListener('focus', onEnter);
}
if (leaveTriggers.length) {
const onLeave = () => leaveTriggers.forEach(t => runFlowFrom(t.node.id, t.screenId));
elDiv.addEventListener('mouseleave', onLeave);
elDiv.addEventListener('blur', onLeave);
}
});
}
+46
View File
@@ -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) {
+8
View File
@@ -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
+172 -5
View File
@@ -113,15 +113,85 @@
<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>
<div id="scormProgress">
<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
entièrement xAPI pour ce jeu, le SCORM continue seul. Réglé
séparément du bouton d'export : ces valeurs sont déjà en base
au moment où on clique "Exporter", jamais retapées à chaque export. -->
<details class="propGroup mb-4" id="xapiSettingsGroup">
<summary>Suivi xAPI (optionnel)</summary>
<div class="propGroupBody">
<p class="help mb-2">Envoie en plus les résultats vers un LRS (Learning Record Store) externe, en parallèle du SCORM ci-dessus. Utilise une clé API dédiée à cet export, jamais tes identifiants LRS principaux.</p>
<div class="field">
<label class="label is-small">URL du LRS</label>
<div class="control"><input class="input is-small" type="text" id="xapiEndpoint" placeholder="https://mon-lrs.exemple.com/xAPI"></div>
</div>
<div class="field">
<label class="label is-small">Login</label>
<div class="control"><input class="input is-small" type="text" id="xapiLogin" autocomplete="off"></div>
</div>
<div class="field">
<label class="label is-small">Mot de passe</label>
<div class="control"><input class="input is-small" type="password" id="xapiPassword" autocomplete="new-password"></div>
<p class="help" id="xapiPasswordHint"></p>
</div>
<div class="field is-grouped" style="align-items:center;">
<div class="control"><button type="button" class="button is-small" onclick="window.forgeSaveXapiSettings()">Enregistrer</button></div>
<span class="hint" id="xapiSaveStatus"></span>
</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;">
<button type="button" class="button is-small" onclick="window.forgeCloseScormModal()">Fermer</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>
@@ -136,13 +206,110 @@
var steps = ["Préparation du jeu...", "Génération du paquet hors ligne...", "Écriture du manifeste SCORM..."];
var stepTimer = null;
var xapiEndpoint = document.getElementById('xapiEndpoint');
var xapiLogin = document.getElementById('xapiLogin');
var xapiPassword = document.getElementById('xapiPassword');
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(); })
.then(function (json) {
xapiEndpoint.value = json.endpoint || '';
xapiLogin.value = json.login || '';
xapiPassword.value = '';
xapiPasswordHint.textContent = json.password_set
? 'Mot de passe déjà enregistré — laisse ce champ vide pour le garder.' : '';
});
}
// Réglages, jamais retapés à chaque export : sauvegardés indépendamment
// du bouton "Exporter" ci-dessous (voir routes/publish/xapi_settings.py).
window.forgeSaveXapiSettings = function () {
xapiSaveStatus.textContent = 'Enregistrement...';
fetch("{{ url_for('xapi_settings_set', slug=game.slug) }}", {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
endpoint: xapiEndpoint.value.trim(), login: xapiLogin.value.trim(),
password: xapiPassword.value || null,
}),
})
.then(function (r) { return r.json(); })
.then(function (json) {
xapiSaveStatus.textContent = json.ok ? 'Enregistré ✓' : 'Échec de l\'enregistrement.';
forgeLoadXapiSettings();
})
.catch(function () { xapiSaveStatus.textContent = 'Échec de l\'enregistrement.'; });
};
// Ouvrir la modale ne lance PLUS l'export tout de suite : le temps que
// l'utilisateur renseigne (et surtout enregistre, bouton séparé
// ci-dessus) ses réglages xAPI serait sinon une course contre le fetch
// d'export, qui pourrait partir avec des réglages pas encore sauvegardés.
// L'export est déclenché explicitement via forgeStartScormExport().
window.forgeOpenScormModal = function () {
progressBox.style.display = 'none';
errorBox.style.display = 'none';
doneBox.style.display = 'none';
xapiSaveStatus.textContent = '';
scoringSaveStatus.textContent = '';
scormVersionSaveStatus.textContent = '';
forgeLoadXapiSettings();
forgeLoadScoringSettings();
forgeLoadScormVersion();
modal.classList.add('is-active');
};
window.forgeStartScormExport = function () {
var stepIndex = 0;
stepText.textContent = steps[0];
progressBox.style.display = '';
errorBox.style.display = 'none';
doneBox.style.display = 'none';
modal.classList.add('is-active');
stepTimer = setInterval(function () {
stepIndex = (stepIndex + 1) % steps.length;
+32 -2
View File
@@ -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
@@ -121,6 +135,21 @@
// sur static/js/play/offline/*.js (ci-dessous) au lieu d'un fetch().
window.FORGE_OFFLINE = true;
var FORGE_PLAY_URLS = {};
// xAPI (voir publish/build_scorm_package.py::build_scorm_zip,
// static/js/play/offline/xapi-client.js) : bolt-on greffé sur le SCORM
// classique ci-dessous (scorm_api_wrapper_url), jamais lié à lui — un
// 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 }},
@@ -146,6 +175,7 @@
<script src="{{ asset_url('js/play/offline/render-element.js') }}"></script>
<script src="{{ asset_url('js/play/offline/full-payload.js') }}"></script>
{% if scorm_api_wrapper_url %}<script src="{{ scorm_api_wrapper_url }}"></script>{% endif %}
<script src="{{ asset_url('js/play/offline/xapi-client.js') }}"></script>
{% endif %}
<script src="{{ asset_url('js/play/actions.js') }}"></script>
<script src="{{ asset_url('js/play/triggers.js') }}"></script>
+6
View File
@@ -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 %}
+35
View File
@@ -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")
+88
View File
@@ -8,6 +8,7 @@ plan — validation ADL SCORM Cloud/Moodle hors du champ de la suite
automatisée)."""
import zipfile
import db
from tests.test_auth import anon_client # noqa: F401
@@ -59,6 +60,93 @@ def test_export_scorm_manifest_lists_index_html_as_the_sco(client, game):
assert "adlcp:scormtype=\"sco\"" in manifest
def test_export_scorm_index_html_has_no_xapi_config_when_unconfigured(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_XAPI_CONFIG = null;" in html
# Toujours chargé, même sans réglage (no-op tant que la config est null).
assert 'src="static/js/play/offline/xapi-client.js"' in html
def test_export_scorm_index_html_embeds_the_xapi_config_when_set(client, game):
db.set_xapi_settings(game, "https://lrs.example.com/xapi/", "monlogin", "monmotdepasse")
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_XAPI_CONFIG = {" in html
assert "https://lrs.example.com/xapi/" in html
assert "monlogin" in html
assert "monmotdepasse" in html
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."""
+32
View File
@@ -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)
+51
View File
@@ -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")
+44
View File
@@ -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")
+66
View File
@@ -0,0 +1,66 @@
"""Réglages xAPI (bolt-on, voir db/games/get_xapi_settings.py,
routes/publish/xapi_settings.py) — le mot de passe ne doit JAMAIS
ressortir en clair (seulement un booléen `password_set`), et un POST
sans mot de passe ne doit pas écraser celui déjà enregistré."""
from tests.test_auth import anon_client # noqa: F401
def test_xapi_settings_get_defaults_to_empty(client, game):
resp = client.get(f"/game/{game}/xapi-settings")
assert resp.status_code == 200
body = resp.get_json()
assert body == {"endpoint": "", "login": "", "password_set": False}
def test_xapi_settings_post_then_get_round_trip(client, game):
resp = client.post(f"/game/{game}/xapi-settings", json={
"endpoint": "https://lrs.example.com/xapi/",
"login": "monlogin",
"password": "monmotdepasse",
})
assert resp.status_code == 200
assert resp.get_json() == {"ok": True}
resp = client.get(f"/game/{game}/xapi-settings")
body = resp.get_json()
assert body["endpoint"] == "https://lrs.example.com/xapi/"
assert body["login"] == "monlogin"
assert body["password_set"] is True
assert "password" not in body
def test_xapi_settings_post_with_blank_password_keeps_the_existing_one(client, game):
client.post(f"/game/{game}/xapi-settings", json={
"endpoint": "https://lrs.example.com/xapi/",
"login": "monlogin",
"password": "monmotdepasse",
})
resp = client.post(f"/game/{game}/xapi-settings", json={
"endpoint": "https://lrs.example.com/xapi/v2/",
"login": "monlogin",
"password": "",
})
assert resp.status_code == 200
import db
settings = db.get_xapi_settings(game)
assert settings["endpoint"] == "https://lrs.example.com/xapi/v2/"
assert settings["password"] == "monmotdepasse"
def test_xapi_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, "xapisettingsisolation@example.com")
_confirm_2fa(anon_client)
_complete_onboarding(anon_client)
try:
resp = anon_client.get("/game/un-projet-qui-nest-pas-le-sien/xapi-settings")
assert resp.status_code == 403
resp = anon_client.post("/game/un-projet-qui-nest-pas-le-sien/xapi-settings", json={})
assert resp.status_code == 403
finally:
_cleanup_project("xapisettingsisolation@example.com")