From f3b72d73c97663a15789b8b4ad40d7f87adecb20 Mon Sep 17 00:00:00 2001 From: william Date: Wed, 2 Sep 2026 07:43:02 +0200 Subject: [PATCH] =?UTF-8?q?Export=20Web/SCORM=20:=20port=20complet=20du=20?= =?UTF-8?q?runtime=20jouable=20c=C3=B4t=C3=A9=20navigateur?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1.2 de la feuille de route produit — un paquet SCORM tourne seul dans le LMS du client, sans serveur Forge Engine disponible. Ajoute static/js/play/offline/ (miroir JS de screens/rendering/ et data_actions/, sous window.FORGE_OFFLINE) pour que variables, score, données d'objet, répéteurs et conditions de visibilité fonctionnent entièrement en mémoire côté navigateur ; branche actions.js et bindings.js dessus au lieu d'un fetch() serveur. Ajoute publish/build_scorm_package.py (paquet statique + imsmanifest.xml SCORM 1.2 + wrapper API SCORM) et le bouton "Exporter (Web/SCORM)". Co-Authored-By: Claude Sonnet 5 --- publish/build_scorm_package.py | 102 ++++++ publish/scorm_manifest.py | 48 +++ routes/play/game_play.py | 1 + routes/public_play/game_play_public.py | 1 + routes/publish/__init__.py | 2 +- routes/publish/export_scorm.py | 27 ++ screens/payload/full_game_payload.py | 35 +- static/js/play/actions.js | 38 +- static/js/play/bindings.js | 17 +- .../__tests__/_load_offline_runtime.js | 40 +++ .../__tests__/actions-offline-branch.test.js | 75 ++++ .../offline/__tests__/apply-actions.test.js | 90 +++++ .../__tests__/compute-operation.test.js | 61 ++++ .../__tests__/filter-repeater-rows.test.js | 103 ++++++ .../offline/__tests__/full-payload.test.js | 74 ++++ .../offline/__tests__/render-element.test.js | 110 ++++++ .../__tests__/render-special-widgets.test.js | 150 ++++++++ .../__tests__/visibility-condition.test.js | 56 +++ static/js/play/offline/apply-actions.js | 163 +++++++++ static/js/play/offline/compute-operation.js | 71 ++++ .../js/play/offline/filter-repeater-rows.js | 158 +++++++++ static/js/play/offline/full-payload.js | 42 +++ static/js/play/offline/render-element.js | 326 ++++++++++++++++++ static/js/play/offline/render-jauge.js | 110 ++++++ static/js/play/offline/render-overlay.js | 50 +++ static/js/play/offline/render-repeater.js | 77 +++++ .../js/play/offline/render-special-widgets.js | 143 ++++++++ static/js/play/offline/scorm-api.js | 66 ++++ .../js/play/offline/visibility-condition.js | 56 +++ templates/base.html | 82 +++++ templates/play.html | 43 ++- tests/test_export_scorm.py | 74 ++++ 32 files changed, 2469 insertions(+), 22 deletions(-) create mode 100644 publish/build_scorm_package.py create mode 100644 publish/scorm_manifest.py create mode 100644 routes/publish/export_scorm.py create mode 100644 static/js/play/offline/__tests__/_load_offline_runtime.js create mode 100644 static/js/play/offline/__tests__/actions-offline-branch.test.js create mode 100644 static/js/play/offline/__tests__/apply-actions.test.js create mode 100644 static/js/play/offline/__tests__/compute-operation.test.js create mode 100644 static/js/play/offline/__tests__/filter-repeater-rows.test.js create mode 100644 static/js/play/offline/__tests__/full-payload.test.js create mode 100644 static/js/play/offline/__tests__/render-element.test.js create mode 100644 static/js/play/offline/__tests__/render-special-widgets.test.js create mode 100644 static/js/play/offline/__tests__/visibility-condition.test.js create mode 100644 static/js/play/offline/apply-actions.js create mode 100644 static/js/play/offline/compute-operation.js create mode 100644 static/js/play/offline/filter-repeater-rows.js create mode 100644 static/js/play/offline/full-payload.js create mode 100644 static/js/play/offline/render-element.js create mode 100644 static/js/play/offline/render-jauge.js create mode 100644 static/js/play/offline/render-overlay.js create mode 100644 static/js/play/offline/render-repeater.js create mode 100644 static/js/play/offline/render-special-widgets.js create mode 100644 static/js/play/offline/scorm-api.js create mode 100644 static/js/play/offline/visibility-condition.js create mode 100644 tests/test_export_scorm.py diff --git a/publish/build_scorm_package.py b/publish/build_scorm_package.py new file mode 100644 index 00000000..1d956c24 --- /dev/null +++ b/publish/build_scorm_package.py @@ -0,0 +1,102 @@ +"""Assemble le zip Web/SCORM d'un jeu (bouton "Exporter (Web/SCORM)", voir +routes/publish/export_scorm.py) : à la différence de build_package.py +(qui embarque Python + Flask, voir player_app.py), ce paquet est +STATIQUE — index.html porte tout le runtime jouable directement en +mémoire (window.FORGE_OFFLINE, voir static/js/play/offline/) puisqu'un +SCORM tourne SEUL dans le LMS du client, sans serveur Forge Engine +disponible. Rien de commun avec le zip .exe hors le sous-ensemble de +static/ et _copy_characters (mêmes assets, mêmes règles de copie +sélective) — réutilisés depuis build_package plutôt que dupliqués.""" +import json +import os +import shutil +import tempfile +import zipfile + +from flask import render_template + +import db +import screens + +from .build_package import _copy_characters, _STATIC_ITEMS +from .scorm_manifest import build_manifest_xml + +_PUBLISH_DIR = os.path.dirname(os.path.abspath(__file__)) +_BASE_DIR = os.path.dirname(_PUBLISH_DIR) + +def _copy_static_assets(staging_dir, slug): + static_dir = os.path.join(staging_dir, "static") + used_forge_characters = screens.list_used_forge_characters(slug) + for item in _STATIC_ITEMS: + if item == "characters": + _copy_characters(static_dir, used_forge_characters) + continue + src = os.path.join(_BASE_DIR, "static", item) + dst = os.path.join(static_dir, item) + os.makedirs(os.path.dirname(dst), exist_ok=True) + if os.path.isdir(src): + # "js/play" inclut déjà static/js/play/offline/ (dont + # scorm-api.js, jamais chargé en ligne mais propre à ce + # paquet) — seuls ses __tests__/ (node:test, inutiles dans + # un LMS) sont exclus. + shutil.copytree(src, dst, ignore=shutil.ignore_patterns("__tests__") if item == "js/play" else None) + else: + shutil.copy2(src, dst) + + +def _list_package_files(staging_dir): + """Chemins relatifs de tout ce qui a déjà été écrit dans staging_dir + (hors imsmanifest.xml lui-même) — le manifeste doit les lister TOUS + pour qu'un validateur strict (ADL SCORM Cloud) accepte le paquet.""" + rels = [] + for root, _dirs, files in os.walk(staging_dir): + for name in files: + full = os.path.join(root, name) + rel = os.path.relpath(full, staging_dir) + if rel != "index.html": + rels.append(rel) + return rels + + +def _zip_dir(staging_dir, zip_path): + with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf: + for root, _dirs, files in os.walk(staging_dir): + for name in files: + full = os.path.join(root, name) + rel = os.path.relpath(full, staging_dir) + zf.write(full, rel) + + +def build_scorm_zip(slug): + """Construit le zip Web/SCORM et renvoie (chemin, nom du jeu) — à + l'appelant de supprimer le zip une fois la réponse HTTP envoyée (voir + routes/publish/export_scorm.py). Doit être appelé DANS une requête + Flask en cours (render_template a besoin du contexte applicatif).""" + game = db.game_meta(slug) + # PLAYER_SHARED (aucun player_id) : un paquet exporté est joué par une + # seule personne à la fois sur son poste, comme /game//play — + # jamais d'état "par joueur" à distinguer hors ligne. + payload = screens.full_game_payload(slug) + + staging_dir = tempfile.mkdtemp(prefix="forge_scorm_") + try: + _copy_static_assets(staging_dir, 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, + ) + with open(os.path.join(staging_dir, "index.html"), "w", encoding="utf-8") as f: + f.write(html) + + package_files = _list_package_files(staging_dir) + manifest_xml = build_manifest_xml(slug, game["name"], package_files) + with open(os.path.join(staging_dir, "imsmanifest.xml"), "w", encoding="utf-8") as f: + f.write(manifest_xml) + + zip_fd, zip_path = tempfile.mkstemp(prefix="forge_scorm_", suffix=".zip") + os.close(zip_fd) + _zip_dir(staging_dir, zip_path) + finally: + shutil.rmtree(staging_dir, ignore_errors=True) + return zip_path, game["name"] diff --git a/publish/scorm_manifest.py b/publish/scorm_manifest.py new file mode 100644 index 00000000..6cf42039 --- /dev/null +++ b/publish/scorm_manifest.py @@ -0,0 +1,48 @@ +"""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).""" +import os +from xml.sax.saxutils import escape + +_MANIFEST_TEMPLATE = """ + + + ADL SCORM + 1.2 + + + + {title} + + {title} + + + + + + +{file_entries} + + + +""" + + +def build_manifest_xml(slug, game_name, package_files): + """`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.""" + file_entries = "\n".join( + ' '.format(escape(rel.replace(os.sep, "/"))) + for rel in package_files + ) + return _MANIFEST_TEMPLATE.format( + identifier=escape(slug), title=escape(game_name), file_entries=file_entries, + ) diff --git a/routes/play/game_play.py b/routes/play/game_play.py index 4e7f8cba..f1b9a9fc 100644 --- a/routes/play/game_play.py +++ b/routes/play/game_play.py @@ -18,6 +18,7 @@ def game_play(slug): payload = screens.full_game_payload(slug) return render_template( "play.html", game=game, screens_data=payload["screens"], payload_json=json.dumps(payload), + asset_url=lambda filename: url_for("static", filename=filename), runtime_payload_url=url_for("runtime_payload", slug=slug), flow_node_run_data_url=url_for("flow_node_run_data", slug=slug, node_id=999999), flow_node_run_variable_url=url_for("flow_node_run_variable", slug=slug, node_id=999999), diff --git a/routes/public_play/game_play_public.py b/routes/public_play/game_play_public.py index 06ba226c..e4ea9a55 100644 --- a/routes/public_play/game_play_public.py +++ b/routes/public_play/game_play_public.py @@ -26,6 +26,7 @@ def game_play_public(slug): resp = app.make_response( render_template( "play.html", game=game, screens_data=payload["screens"], payload_json=json.dumps(payload), + asset_url=lambda filename: url_for("static", filename=filename), runtime_payload_url=url_for("runtime_payload_public", slug=slug), flow_node_run_data_url=url_for("flow_node_run_data_public", slug=slug, node_id=999999), flow_node_run_variable_url=url_for("flow_node_run_variable_public", slug=slug, node_id=999999), diff --git a/routes/publish/__init__.py b/routes/publish/__init__.py index 4961124c..90af3a80 100644 --- a/routes/publish/__init__.py +++ b/routes/publish/__init__.py @@ -1 +1 @@ -from . import publish_game +from . import publish_game, export_scorm diff --git a/routes/publish/export_scorm.py b/routes/publish/export_scorm.py new file mode 100644 index 00000000..b5386e6e --- /dev/null +++ b/routes/publish/export_scorm.py @@ -0,0 +1,27 @@ +import os + +from flask import after_this_request, send_file + +import db +from publish.build_scorm_package import build_scorm_zip + +from core.flask_app import app + + +@app.route("/game//export-scorm", methods=["POST"]) +def export_scorm(slug): + """Construit et renvoie le paquet Web/SCORM autonome du jeu (voir + publish/build_scorm_package.py) — même garde d'accès que + /game//publish (core/auth_guard.py).""" + zip_path, game_name = build_scorm_zip(slug) + + @after_this_request + def _cleanup(response): + try: + os.remove(zip_path) + except OSError: + pass + return response + + download_name = f"{db.slugify(game_name)}-scorm.zip" + return send_file(zip_path, as_attachment=True, download_name=download_name, mimetype="application/zip") diff --git a/screens/payload/full_game_payload.py b/screens/payload/full_game_payload.py index 678d17af..c42aa2fa 100644 --- a/screens/payload/full_game_payload.py +++ b/screens/payload/full_game_payload.py @@ -6,6 +6,7 @@ from ..flow.list_flow_nodes import list_flow_nodes from ..flow.list_flow_edges import list_flow_edges from ..animations.list_animation_clips import list_animation_clips from ..element_types.list_element_types import list_element_types +from ..element_types.load_template_tree import _load_template_tree from ..rendering.personnage_data import resolve_personnage_animations from ..scenes.list_scene_objects import list_scene_objects from ..scenes.render_scene_object import render_scene_object @@ -100,7 +101,16 @@ def full_game_payload(slug, player_id=db.PLAYER_SHARED): {**{fname: r.get(col) for fname, col in cols.items()}, "id": r["id"]} for r in rows ] - fields_meta[str(d["id"])] = [{"name": f["name"], "type": f["type"]} for f in full["fields"]] + # min_value/max_value (2.2 — bornage automatique, voir + # apply_data_action.py) : nécessaires ici pour que le port + # client-side de l'export Web/SCORM (static/js/play/offline/, + # voir le plan "port complet du runtime jouable côté navigateur") + # puisse reproduire le même bornage sans aller-retour serveur — + # inoffensif pour le runtime en ligne, qui ignore ces deux clés. + fields_meta[str(d["id"])] = [ + {"name": f["name"], "type": f["type"], "min_value": f.get("min_value"), "max_value": f.get("max_value")} + for f in full["fields"] + ] # element_type_id -> id de son écran-modèle : nécessaire côté client (voir # collectAnimationClips() dans play.html) pour savoir, quand un élément de @@ -111,6 +121,21 @@ def full_game_payload(slug, player_id=db.PLAYER_SHARED): # modèle est utilisé ailleurs. element_types = {str(t["id"]): t["template_screen_id"] for t in list_element_types(slug)} + # Arbre complet (éléments + descendants) de l'écran-modèle de chaque + # élément de jeu réutilisable — voir screens/element_types/ + # load_template_tree.py, appelée aujourd'hui à CHAQUE rendu (aperçu + # créateur ET jeu publié en ligne). Nécessaire ici pour que le port + # client-side de l'export Web/SCORM (static/js/play/offline/, + # rendu d'un exemplaire d'élément de jeu OU d'un modèle de ligne de + # Répéteur) puisse le rejouer sans base de données — inoffensif pour + # le runtime en ligne, qui l'ignore. + element_type_templates = {} + for type_id_str, template_screen_id in element_types.items(): + if template_screen_id: + tree = _load_template_tree(slug, int(type_id_str)) + if tree: + element_type_templates[type_id_str] = tree + # Instantané des variables globales, pour qu'un nœud Condition puisse en # tester une côté CLIENT (evaluateConditionClause, templates/play.html) — # {{$var}} (Répéteur/Condition de visibilité), lui, reste résolu côté @@ -122,8 +147,16 @@ def full_game_payload(slug, player_id=db.PLAYER_SHARED): for v in db.list_global_variables_for_player(slug, player_id) } + # id de définition -> nom de l'objet (voir filter_repeater_rows.py côté + # serveur, static/js/play/offline/filter-repeater-rows.js côté port + # hors ligne) : nécessaire pour résoudre une référence "{{Objet.champ}}" + # (Répéteur/Donnée liée/Condition de visibilité) — data/fields_meta + # ci-dessus sont déjà indexés par id, jamais par nom. + definition_names = {str(d["id"]): d["name"] for d in definitions} + return { "screens": payload_screens, "flows": flows, "animations": animations, "element_types": element_types, "data": data, "fields_meta": fields_meta, "variables": variables, "personnage_animations": personnage_animations, + "definition_names": definition_names, "element_type_templates": element_type_templates, } diff --git a/static/js/play/actions.js b/static/js/play/actions.js index 339916a5..7d45e089 100644 --- a/static/js/play/actions.js +++ b/static/js/play/actions.js @@ -252,6 +252,13 @@ function runActionNode(node) { const ms = isNaN(seconds) ? 0 : Math.max(0, seconds) * 1000; return new Promise(function(resolve) { setTimeout(resolve, ms); }); } else if (node.action_type === 'modifier_donnee') { + // Export Web/SCORM (voir static/js/play/offline/apply-actions.js) : + // aucun serveur disponible dans le paquet exporté — la même mutation + // s'applique directement sur gameData, en mémoire, dans le navigateur. + if (window.FORGE_OFFLINE) { + forgeApplyDataActionOffline(gameData, node); + return refreshRuntimeData(); + } const runDataUrl = FORGE_PLAY_URLS.flowNodeRunData.replace('999999', node.id); // clicked_row_id/last_inserted_row_id : utilisés côté serveur // SEULEMENT si ce nœud vise respectivement CLICKED_ROW_ID ou @@ -264,6 +271,10 @@ function runActionNode(node) { .then(r => r.json()) .then(() => refreshRuntimeData()); } else if (node.action_type === 'modifier_variable') { + if (window.FORGE_OFFLINE) { + forgeApplyVariableActionOffline(gameData, node); + return refreshRuntimeData(); + } const runVarUrl = FORGE_PLAY_URLS.flowNodeRunVariable.replace('999999', node.id); return fetch(runVarUrl, {method: 'POST'}) .then(r => r.json()) @@ -271,22 +282,35 @@ function runActionNode(node) { } else if (node.action_type === 'modifier_score') { // Score/progression (voir db/scoring/, screens/data_actions/ // apply_score_action.py) : même patron que "Modifier une variable - // globale" ci-dessus, calculé et écrit entièrement côté serveur. + // globale" ci-dessus, calculé et écrit côté serveur EN LIGNE, ou + // directement en mémoire dans le paquet exporté (voir apply-actions.js). + if (window.FORGE_OFFLINE) { + forgeApplyScoreActionOffline(gameData, node); + return refreshRuntimeData(); + } const runScoreUrl = FORGE_PLAY_URLS.flowNodeRunScore.replace('999999', node.id); return fetch(runScoreUrl, {method: 'POST'}) .then(r => r.json()) .then(() => refreshRuntimeData()); } else if (node.action_type === 'definir_statut_partie') { + if (window.FORGE_OFFLINE) { + forgeApplyStatusActionOffline(gameData, node); + return refreshRuntimeData(); + } const runStatusUrl = FORGE_PLAY_URLS.flowNodeRunStatus.replace('999999', node.id); return fetch(runStatusUrl, {method: 'POST'}) .then(r => r.json()) .then(() => refreshRuntimeData()); } else if (node.action_type === 'ajouter_ligne') { - // Phase 4 — crée une ligne VIDE côté serveur ; son id devient - // window.lastInsertedRowId pour que les nœuds "Modifier une donnée" - // enchaînés (ciblant LAST_INSERTED_ROW_ID, "➕ Dernière ligne - // ajoutée") sachent quelle ligne renseigner. refreshRuntimeData() - // affiche la nouvelle ligne dans tout Répéteur lié dès ce round-trip. + // Phase 4 — crée une ligne VIDE (côté serveur en ligne, en mémoire + // hors ligne) ; son id devient window.lastInsertedRowId pour que les + // nœuds "Modifier une donnée" enchaînés (ciblant LAST_INSERTED_ROW_ID, + // "➕ Dernière ligne ajoutée") sachent quelle ligne renseigner. + // refreshRuntimeData() affiche la nouvelle ligne dans tout Répéteur lié. + if (window.FORGE_OFFLINE) { + window.lastInsertedRowId = forgeApplyAddRowActionOffline(gameData, node); + return refreshRuntimeData(); + } const runAddRowUrl = FORGE_PLAY_URLS.flowNodeRunAddRow.replace('999999', node.id); return fetch(runAddRowUrl, { method: 'POST' }) .then(r => r.json()) @@ -418,6 +442,6 @@ function stopAllSpriteAnimations() { if (typeof module !== 'undefined' && module.exports) { module.exports = { resolveSpriteFrames, runSpriteAnimation, stopAllSpriteAnimations, activeSpriteAnimations, - applyObjectProperty, clampSceneObjectPosition, + applyObjectProperty, clampSceneObjectPosition, runActionNode, }; } diff --git a/static/js/play/bindings.js b/static/js/play/bindings.js index 0358c39b..05f6ef42 100644 --- a/static/js/play/bindings.js +++ b/static/js/play/bindings.js @@ -158,9 +158,23 @@ function applyOpenRowBindings() { // "Modifier un élément" (ex: masquer une boîte) déclenchée par le même // clic, puisque ces changements ne sont jamais enregistrés côté serveur. function refreshRuntimeData() { + // Export Web/SCORM (voir le plan "port complet du runtime jouable côté + // navigateur") : window.FORGE_OFFLINE (posé UNIQUEMENT dans le paquet + // exporté, jamais dans /game//play ni /jouer/) recalcule le + // payload EN MÉMOIRE (static/js/play/offline/full-payload.js) au lieu + // d'un fetch — le mécanisme de repérage/remplacement DOM qui suit + // (_forgeApplyFreshRuntimeData) reste ensuite EXACTEMENT le même code, + // en ligne comme hors ligne. + if (window.FORGE_OFFLINE) { + forgeRecomputeFullPayloadOffline(gameData); + return Promise.resolve().then(function () { return _forgeApplyFreshRuntimeData(gameData); }); + } return fetch(FORGE_PLAY_URLS.runtimePayload) .then(r => r.json()) - .then(newData => { + .then(_forgeApplyFreshRuntimeData); +} + +function _forgeApplyFreshRuntimeData(newData) { gameData = newData; screensData = gameData.screens; const screenData = screensData.find(s => s.id === window.currentScreenId); @@ -305,5 +319,4 @@ function refreshRuntimeData() { // qu'on vient de débloquer), donc l'affichage doit se mettre à jour // tout de suite, sans attendre un aller-retour d'écran. runScreenShowTriggers(window.currentScreenId); - }); } diff --git a/static/js/play/offline/__tests__/_load_offline_runtime.js b/static/js/play/offline/__tests__/_load_offline_runtime.js new file mode 100644 index 00000000..d9162aa1 --- /dev/null +++ b/static/js/play/offline/__tests__/_load_offline_runtime.js @@ -0,0 +1,40 @@ +// Charge tout le runtime hors ligne dans l'ordre où templates/play.html +// le fera (chaque fichier pose ses fonctions en global, comme en vrai +// navigateur) — nécessaire pour tester forgeRenderElementHtml() sur un +// widget à special_render (jauge, répéteur...), dont le rendu vit dans +// des fichiers frères de render-element.js, jamais require()-és entre eux +// directement (éviterait un cycle, voir la note en bas de render-element.js). +require('../../conditions.js'); +global.compareValues = require('../../conditions.js').compareValues; + +const filterMod = require('../filter-repeater-rows.js'); +Object.assign(global, filterMod); + +const visibilityMod = require('../visibility-condition.js'); +Object.assign(global, visibilityMod); + +const computeMod = require('../compute-operation.js'); +Object.assign(global, computeMod); + +const applyMod = require('../apply-actions.js'); +Object.assign(global, applyMod); + +const renderElementMod = require('../render-element.js'); +Object.assign(global, renderElementMod); + +const jaugeMod = require('../render-jauge.js'); +Object.assign(global, jaugeMod); + +const overlayMod = require('../render-overlay.js'); +Object.assign(global, overlayMod); + +const repeaterMod = require('../render-repeater.js'); +Object.assign(global, repeaterMod); + +const specialMod = require('../render-special-widgets.js'); +Object.assign(global, specialMod); + +module.exports = Object.assign( + {}, filterMod, visibilityMod, computeMod, applyMod, renderElementMod, + jaugeMod, overlayMod, repeaterMod, specialMod +); diff --git a/static/js/play/offline/__tests__/actions-offline-branch.test.js b/static/js/play/offline/__tests__/actions-offline-branch.test.js new file mode 100644 index 00000000..8d85b9e4 --- /dev/null +++ b/static/js/play/offline/__tests__/actions-offline-branch.test.js @@ -0,0 +1,75 @@ +// Export Web/SCORM — vérifie que static/js/play/actions.js bascule bien +// sur les fonctions forgeApply*Offline (apply-actions.js) quand +// window.FORGE_OFFLINE est actif, au lieu de fetch() vers le serveur +// (voir bindings.js, qui bascule déjà refreshRuntimeData() de son côté). +const test = require('node:test'); +const assert = require('node:assert/strict'); + +require('./_load_offline_runtime.js'); + +global.window = global.window || {}; +global.gameData = global.gameData || {}; +const { runActionNode } = require('../../actions.js'); + +function withOfflineFetchGuard(fn) { + const previousFetch = global.fetch; + const previousOffline = global.window.FORGE_OFFLINE; + const previousGameData = global.gameData; + const previousRefresh = global.refreshRuntimeData; + global.window.FORGE_OFFLINE = true; + global.fetch = () => { throw new Error('fetch() ne doit jamais être appelé en mode FORGE_OFFLINE'); }; + let refreshCalls = 0; + global.refreshRuntimeData = () => { refreshCalls++; return Promise.resolve(); }; + try { + fn(() => refreshCalls); + } finally { + global.window.FORGE_OFFLINE = previousOffline; + global.fetch = previousFetch; + global.gameData = previousGameData; + global.refreshRuntimeData = previousRefresh; + } +} + +test("modifier_variable — mute gameData en mémoire sans fetch()", () => { + withOfflineFetchGuard((getRefreshCalls) => { + global.gameData = { variables: { score_var: { value: '10', type: 'nombre_entier' } } }; + const node = { + action_type: 'modifier_variable', + target_variable: 'score_var', data_operation: 'incrementer', data_value: '5', + }; + runActionNode(node); + assert.equal(global.gameData.variables.score_var.value, '15'); + assert.equal(getRefreshCalls(), 1); + }); +}); + +test("modifier_score — mute gameData.scoring sans fetch()", () => { + withOfflineFetchGuard((getRefreshCalls) => { + global.gameData = { scoring: { score: 0, status: 'non_commence' } }; + const node = { action_type: 'modifier_score', data_operation: 'incrementer', data_value: '3' }; + runActionNode(node); + assert.equal(global.gameData.scoring.score, 3); + assert.equal(getRefreshCalls(), 1); + }); +}); + +test("definir_statut_partie — mute gameData.scoring.status sans fetch()", () => { + withOfflineFetchGuard((getRefreshCalls) => { + global.gameData = { scoring: { score: 0, status: 'non_commence' } }; + const node = { action_type: 'definir_statut_partie', data_value: 'reussi' }; + runActionNode(node); + assert.equal(global.gameData.scoring.status, 'reussi'); + assert.equal(getRefreshCalls(), 1); + }); +}); + +test("ajouter_ligne — crée une ligne en mémoire et fixe window.lastInsertedRowId sans fetch()", () => { + withOfflineFetchGuard((getRefreshCalls) => { + global.gameData = { data: { '1': [{ id: 5 }] }, definition_names: {}, fields_meta: { '1': [] } }; + const node = { action_type: 'ajouter_ligne', target_definition_id: 1 }; + runActionNode(node); + assert.equal(global.window.lastInsertedRowId, 6); + assert.equal(global.gameData.data['1'].length, 2); + assert.equal(getRefreshCalls(), 1); + }); +}); diff --git a/static/js/play/offline/__tests__/apply-actions.test.js b/static/js/play/offline/__tests__/apply-actions.test.js new file mode 100644 index 00000000..8f257d9a --- /dev/null +++ b/static/js/play/offline/__tests__/apply-actions.test.js @@ -0,0 +1,90 @@ +// Export Web/SCORM — vérifie que les mutations hors ligne (apply-actions.js) +// produisent le même résultat que leur équivalent serveur Python (voir +// screens/data_actions/) sur les mêmes entrées. +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + forgeApplyVariableActionOffline, forgeApplyScoreActionOffline, forgeApplyStatusActionOffline, + forgeApplyDataActionOffline, forgeApplyAddRowActionOffline, +} = require('../apply-actions.js'); + +function baseGameData() { + return { + variables: { + score_var: { value: '10', type: 'nombre_entier' }, + flag: { value: '0', type: 'booleen' }, + texte: { value: 'salut', type: 'texte' }, + }, + scoring: { score: 0, status: 'non_commence' }, + data: { + '1': [{ id: 1, points: 5, nom: 'Alice' }, { id: 2, points: 8, nom: 'Bob' }], + }, + fields_meta: { + '1': [ + { name: 'points', type: 'nombre_entier', min_value: 0, max_value: 10 }, + { name: 'nom', type: 'texte', min_value: null, max_value: null }, + ], + }, + }; +} + +test('modifier_variable — incrémenter un entier', () => { + const gd = baseGameData(); + const ok = forgeApplyVariableActionOffline(gd, { target_variable: 'score_var', data_operation: 'incrementer', data_value: '5' }); + assert.equal(ok, true); + assert.equal(gd.variables.score_var.value, '15'); +}); + +test('modifier_variable — basculer un booléen (vocabulaire oui/vrai)', () => { + const gd = baseGameData(); + forgeApplyVariableActionOffline(gd, { target_variable: 'flag', data_operation: 'basculer_bool' }); + assert.equal(gd.variables.flag.value, '1'); +}); + +test('modifier_variable — variable inconnue échoue proprement', () => { + const gd = baseGameData(); + const ok = forgeApplyVariableActionOffline(gd, { target_variable: 'inexistante', data_operation: 'incrementer', data_value: '1' }); + assert.equal(ok, false); +}); + +test('modifier_score — un seul score, pas de notion per-player hors ligne', () => { + const gd = baseGameData(); + forgeApplyScoreActionOffline(gd, { data_operation: 'incrementer', data_value: '10' }); + forgeApplyScoreActionOffline(gd, { data_operation: 'incrementer', data_value: '5' }); + assert.equal(gd.scoring.score, 15); +}); + +test('definir_statut_partie — rejette un statut inconnu', () => { + const gd = baseGameData(); + assert.equal(forgeApplyStatusActionOffline(gd, { data_value: 'n_importe_quoi' }), false); + assert.equal(forgeApplyStatusActionOffline(gd, { data_value: 'reussi' }), true); + assert.equal(gd.scoring.status, 'reussi'); +}); + +test('modifier_donnee — bornage min/max comme apply_data_action.py', () => { + const gd = baseGameData(); + forgeApplyDataActionOffline(gd, { + target_definition_id: 1, target_row_id: 1, target_field: 'points', + data_operation: 'incrementer', data_value: '100', + }); + assert.equal(gd.data['1'][0].points, 10); // borné à max_value=10, pas 105 +}); + +test('modifier_donnee — ligne/champ/objet inconnu échoue proprement', () => { + const gd = baseGameData(); + assert.equal(forgeApplyDataActionOffline(gd, { target_definition_id: 1, target_row_id: 999, target_field: 'points', data_operation: 'incrementer', data_value: '1' }), false); + assert.equal(forgeApplyDataActionOffline(gd, { target_definition_id: 1, target_row_id: 1, target_field: 'inconnu', data_operation: 'incrementer', data_value: '1' }), false); +}); + +test('ajouter_ligne — ligne vide, id auto-incrémenté, booléen à 0', () => { + const gd = baseGameData(); + gd.fields_meta['1'].push({ name: 'actif', type: 'booleen', min_value: null, max_value: null }); + const newId = forgeApplyAddRowActionOffline(gd, { target_definition_id: 1 }); + assert.equal(newId, 3); // max existant (2) + 1 + const row = gd.data['1'][2]; + assert.equal(row.id, 3); + assert.equal(row.points, null); + assert.equal(row.nom, null); + assert.equal(row.actif, 0); +}); diff --git a/static/js/play/offline/__tests__/compute-operation.test.js b/static/js/play/offline/__tests__/compute-operation.test.js new file mode 100644 index 00000000..239b3d79 --- /dev/null +++ b/static/js/play/offline/__tests__/compute-operation.test.js @@ -0,0 +1,61 @@ +// Export Web/SCORM — vérifie que forgeComputeNewValue (port JS) produit +// EXACTEMENT les mêmes résultats que compute_new_value (Python, voir +// screens/data_actions/compute_operation.py) sur les mêmes entrées — +// toute divergence casserait silencieusement un jeu une fois exporté. +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { forgeComputeNewValue, forgePyMod } = require('../compute-operation.js'); + +test('definir_texte / definir_nombre / bool', () => { + assert.equal(forgeComputeNewValue('definir_texte', 'x', 'bonjour', false), 'bonjour'); + assert.equal(forgeComputeNewValue('definir_texte', 'x', '', false), ''); + assert.equal(forgeComputeNewValue('definir_nombre', 0, '3.7', false), 3); + assert.equal(forgeComputeNewValue('definir_nombre', 0, '3.7', true), 3.7); + assert.equal(forgeComputeNewValue('definir_nombre', 0, 'pas un nombre', false), 0); + assert.equal(forgeComputeNewValue('definir_bool_vrai', 0, null, false), 1); + assert.equal(forgeComputeNewValue('definir_bool_faux', 1, null, false), 0); + assert.equal(forgeComputeNewValue('basculer_bool', 1, null, false), 0); + assert.equal(forgeComputeNewValue('basculer_bool', 0, null, false), 1); +}); + +test('incrementer / decrementer', () => { + assert.equal(forgeComputeNewValue('incrementer', 10, '5', false), 15); + assert.equal(forgeComputeNewValue('decrementer', 10, '5', false), 5); + assert.equal(forgeComputeNewValue('incrementer', null, '', false), 0); + assert.equal(forgeComputeNewValue('incrementer', 10.5, '0.5', true), 11); +}); + +test('multiplier / diviser / modulo', () => { + assert.equal(forgeComputeNewValue('multiplier', 4, '3', false), 12); + assert.equal(forgeComputeNewValue('diviser', 10, '4', false), 2); // int(2.5) = 2, pas d'arrondi + assert.equal(forgeComputeNewValue('diviser', 10, '4', true), 2.5); + assert.equal(forgeComputeNewValue('diviser', 10, '0', false), 10); // garde-fou division par zéro + assert.equal(forgeComputeNewValue('modulo', 10, '3', false), 1); + // Signe du modulo aligné sur Python (suit le diviseur), pas le JS natif + // (suivrait le dividende : -10 % 3 === -1 en JS, 2 en Python). + assert.equal(forgePyMod(-10, 3), 2); + assert.equal(forgeComputeNewValue('modulo', -10, '3', false), 2); +}); + +test('minimum / maximum', () => { + assert.equal(forgeComputeNewValue('minimum', 5, '10', false), 10); + assert.equal(forgeComputeNewValue('minimum', 15, '10', false), 15); + assert.equal(forgeComputeNewValue('maximum', 15, '10', false), 10); + assert.equal(forgeComputeNewValue('maximum', 5, '10', false), 5); + assert.equal(forgeComputeNewValue('minimum', 5, 'pas un nombre', false), 5); // inchangé +}); + +test('alea — bornes triées, entier vs décimal', () => { + for (let i = 0; i < 50; i++) { + const v = forgeComputeNewValue('alea', 0, '5,1', false); // bornes inversées, doit se corriger + assert.ok(Number.isInteger(v)); + assert.ok(v >= 1 && v <= 5); + } + const d = forgeComputeNewValue('alea', 0, '1,2', true); + assert.ok(d >= 1 && d <= 2); +}); + +test('opération inconnue lève une erreur (comme ValueError côté Python)', () => { + assert.throws(() => forgeComputeNewValue('n_importe_quoi', 0, '1', false)); +}); diff --git a/static/js/play/offline/__tests__/filter-repeater-rows.test.js b/static/js/play/offline/__tests__/filter-repeater-rows.test.js new file mode 100644 index 00000000..077c1ee8 --- /dev/null +++ b/static/js/play/offline/__tests__/filter-repeater-rows.test.js @@ -0,0 +1,103 @@ +// Export Web/SCORM — vérifie que le port JS du moteur de filtrage +// (screens/rendering/filter_repeater_rows.py) produit les mêmes résultats +// que l'original Python sur les mêmes entrées. +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + forgeDecodeClauses, forgeResolveVariablePath, forgeFieldColumn, forgeSlugifyFieldName, + forgeResolveFilterValue, forgeFilterRowsByClauses, forgeFilterRepeaterRows, +} = require('../filter-repeater-rows.js'); + +function baseGameData() { + return { + variables: { + niveau: { value: '3', type: 'nombre_entier' }, + perso: { value: JSON.stringify({ nom: 'Zelda', armes: ['épée', 'arc'] }), type: 'objet' }, + }, + definition_names: { '1': 'Progression', '2': 'Ennemis' }, + fields_meta: { + '1': [{ name: 'niveau_actuel', type: 'nombre_entier' }, { name: 'boss', type: 'relation' }], + '2': [{ name: 'nom', type: 'texte' }, { name: 'pv', type: 'nombre_entier' }], + }, + data: { + '1': [{ id: 1, niveau_actuel: 4, boss_id: 7 }], + '2': [ + { id: 1, nom: 'Slime', pv: 10 }, + { id: 2, nom: 'Dragon', pv: 100 }, + ], + }, + }; +} + +test('forgeSlugifyFieldName correspond à db.slugify(...).replace("-","_")', () => { + assert.equal(forgeSlugifyFieldName('Nom Complet'), 'nom_complet'); + assert.equal(forgeSlugifyFieldName('Âge (ans)'), 'ge_ans'); +}); + +test('forgeFieldColumn ajoute _id pour un champ relation', () => { + const fields = [{ name: 'boss', type: 'relation' }, { name: 'nom', type: 'texte' }]; + assert.equal(forgeFieldColumn(fields, 'boss'), 'boss_id'); + assert.equal(forgeFieldColumn(fields, 'nom'), 'nom'); +}); + +test('forgeResolveVariablePath — chemin objet/tableau, chemin vide, JSON invalide', () => { + const raw = JSON.stringify({ nom: 'Zelda', armes: ['épée', 'arc'] }); + assert.equal(forgeResolveVariablePath(raw, '.nom'), 'Zelda'); + assert.equal(forgeResolveVariablePath(raw, '.armes[0]'), 'épée'); + assert.equal(forgeResolveVariablePath('5', ''), '5'); + assert.equal(forgeResolveVariablePath('pas du json', '.x'), null); + assert.equal(forgeResolveVariablePath(raw, '.absent'), null); +}); + +test('forgeResolveFilterValue — {{$variable.chemin}}', () => { + const gd = baseGameData(); + assert.equal(forgeResolveFilterValue(gd, '{{$niveau}}'), '3'); + assert.equal(forgeResolveFilterValue(gd, '{{$perso.nom}}'), 'Zelda'); + assert.equal(forgeResolveFilterValue(gd, '{{$inexistante}}'), null); +}); + +test('forgeResolveFilterValue — {{Objet.champ}} (ligne la plus récente)', () => { + const gd = baseGameData(); + assert.equal(forgeResolveFilterValue(gd, '{{Progression.niveau_actuel}}'), 4); + assert.equal(forgeResolveFilterValue(gd, '{{ObjetInconnu.champ}}'), null); +}); + +test('forgeResolveFilterValue — valeur fixe inchangée', () => { + const gd = baseGameData(); + assert.equal(forgeResolveFilterValue(gd, 'texte fixe'), 'texte fixe'); +}); + +test('forgeFilterRowsByClauses — ET (toutes vraies) vs OU (au moins une)', () => { + const gd = baseGameData(); + const rows = gd.data['2']; + const clauses = [ + { champ: 'nom', operateur: 'egal', valeur: 'Dragon' }, + { champ: 'pv', operateur: 'superieur', valeur: '50' }, + ]; + assert.deepEqual(forgeFilterRowsByClauses(gd, '2', rows, clauses, 'et').map(r => r.id), [2]); + + const orClauses = [ + { champ: 'nom', operateur: 'egal', valeur: 'Slime' }, + { champ: 'pv', operateur: 'superieur', valeur: '50' }, + ]; + assert.deepEqual(forgeFilterRowsByClauses(gd, '2', rows, orClauses, 'ou').map(r => r.id), [1, 2]); +}); + +test('forgeFilterRepeaterRows — ancien format 2 filtres fixes (repli)', () => { + const gd = baseGameData(); + const attrs = { _filtre_champ: 'nom', _filtre_operateur: 'egal', _filtre_valeur: 'Slime' }; + const result = forgeFilterRepeaterRows(gd, '2', gd.data['2'], attrs); + assert.deepEqual(result.map(r => r.id), [1]); +}); + +test('forgeDecodeClauses — format JSON illimité prioritaire sur le legacy', () => { + const attrs = { + _filtres_json: JSON.stringify([{ champ: 'pv', operateur: 'superieur_egal', valeur: '10' }]), + _filtre_combinateur: 'ou', + _filtre_champ: 'ignoré', + }; + const decoded = forgeDecodeClauses(attrs, '_filtres_json', '_filtre_combinateur', '_filtre'); + assert.equal(decoded.clauses.length, 1); + assert.equal(decoded.combinator, 'ou'); +}); diff --git a/static/js/play/offline/__tests__/full-payload.test.js b/static/js/play/offline/__tests__/full-payload.test.js new file mode 100644 index 00000000..8bcd64dc --- /dev/null +++ b/static/js/play/offline/__tests__/full-payload.test.js @@ -0,0 +1,74 @@ +// Export Web/SCORM — étape 5 : orchestrateur full-payload.js, appelé par +// refreshRuntimeData() (static/js/play/bindings.js) en mode hors ligne. +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { forgeRecomputeFullPayloadOffline } = require('../full-payload.js'); +require('./_load_offline_runtime.js'); // pour forgeRenderElementHtml + tous ses dépendants + +function baseGameData() { + return { + variables: { score_var: { value: '10', type: 'nombre_entier' } }, + scoring: { score: 0, status: 'non_commence' }, + definition_names: {}, + fields_meta: {}, + data: {}, + personnage_animations: {}, + element_type_templates: {}, + screens: [ + { + id: 1, kind: 'document', preview_aspect: '9/16', + elements: [ + { + id: 10, widget: 'texte', content: 'Score : x', attributes: {}, style: {}, parent_id: null, + rendered_html: '

ancien rendu

', + }, + ], + }, + { + id: 2, kind: 'jeu_2d', scene_width: 960, scene_height: 540, + elements: [{ id: 20, kind: 'decor', rendered_html: '' }], + }, + ], + }; +} + +test('recalcule rendered_html pour un écran document', () => { + const gd = baseGameData(); + forgeRecomputeFullPayloadOffline(gd); + assert.ok(gd.screens[0].elements[0].rendered_html.includes('Score : x')); + assert.ok(gd.screens[0].elements[0].rendered_html.startsWith('

{ + const gd = baseGameData(); + forgeRecomputeFullPayloadOffline(gd); + assert.equal(gd.screens[1].elements[0].rendered_html, ''); +}); + +test('un élément imbriqué reçoit aussi son PROPRE rendered_html (pas seulement son ancêtre)', () => { + const gd = baseGameData(); + gd.screens[0].elements.push({ + id: 11, widget: 'texte', content: 'enfant', attributes: {}, style: {}, parent_id: 10, rendered_html: '', + }); + gd.screens[0].elements[0].widget = 'conteneur'; // devient le parent + forgeRecomputeFullPayloadOffline(gd); + const child = gd.screens[0].elements.find(e => e.id === 11); + assert.ok(child.rendered_html.includes('enfant')); +}); + +test('reflète une mutation déjà appliquée (score/variable) via la Donnée liée/condition de visibilité', () => { + const gd = baseGameData(); + gd.variables.niveau = { value: '5', type: 'nombre_entier' }; + gd.screens[0].elements[0].content = 'Visible'; + gd.screens[0].elements[0].attributes = { + _visibilite_cond_mode: 'variable', _visibilite_cond_variable: 'niveau', + _visibilite_cond_operateur: 'superieur_egal', _visibilite_cond_valeur: '5', + }; + forgeRecomputeFullPayloadOffline(gd); + assert.ok(gd.screens[0].elements[0].rendered_html.includes('Visible')); + + gd.variables.niveau.value = '1'; // mutation ultérieure (ex. modifier_variable) — score chute sous le seuil + forgeRecomputeFullPayloadOffline(gd); + assert.ok(gd.screens[0].elements[0].rendered_html.includes('visibilityGated')); +}); diff --git a/static/js/play/offline/__tests__/render-element.test.js b/static/js/play/offline/__tests__/render-element.test.js new file mode 100644 index 00000000..ca111510 --- /dev/null +++ b/static/js/play/offline/__tests__/render-element.test.js @@ -0,0 +1,110 @@ +// Export Web/SCORM — port des widgets "simples" de render_element_html.py +// et du dispatcher lui-même (donnée liée, visibilité). Les 10 widgets à +// special_render (jauge, répéteur, personnage...) sont testés séparément +// dans render-special-widgets.test.js (dispatcher complet, via +// _load_offline_runtime.js). +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { forgeRenderElementHtml, forgeStyleString, forgeAttrString } = require('../render-element.js'); + +function baseGameData() { + return { variables: {}, definition_names: {}, fields_meta: {}, data: {} }; +} + +test('texte simple — échappement HTML, saut de ligne ->
', () => { + const el = { id: 1, widget: 'texte', content: 'a < b\nligne 2', attributes: {}, style: {} }; + const html = forgeRenderElementHtml(el, baseGameData(), {}, {}); + assert.ok(html.startsWith('

ligne 2')); + assert.ok(html.includes('data-element-id="1"')); +}); + +test('titre — balise dynamique h1/h2/h3 selon _niveau', () => { + const el = { id: 2, widget: 'titre', content: 'Bienvenue', attributes: { _niveau: '1' }, style: {} }; + const html = forgeRenderElementHtml(el, baseGameData(), {}, {}); + assert.ok(html.startsWith('

{ + const el = { id: 3, widget: 'bouton', content: 'Valider', attributes: {}, style: {} }; + const html = forgeRenderElementHtml(el, baseGameData(), {}, {}); + assert.ok(html.startsWith(' + + + + + {% endif %} {% block content %}{% endblock %} diff --git a/templates/play.html b/templates/play.html index 2c13250f..309a434c 100644 --- a/templates/play.html +++ b/templates/play.html @@ -4,12 +4,12 @@ {{ game.name }} — Jouer - - + + - - -