Export Web/SCORM : port complet du runtime jouable côté navigateur
Build and deploy / test-python (push) Successful in 9m20s
Build and deploy / test-js (push) Successful in 1m6s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped

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 <noreply@anthropic.com>
This commit is contained in:
william
2026-09-02 07:43:02 +02:00
co-authored by Claude Sonnet 5
parent 9776fbc057
commit f3b72d73c9
32 changed files with 2469 additions and 22 deletions
+74
View File
@@ -0,0 +1,74 @@
"""Export Web/SCORM (bouton "Exporter (Web/SCORM)", voir
routes/publish/export_scorm.py et publish/build_scorm_package.py) —
paquet STATIQUE (aucun Python/Flask embarqué, contrairement à
publish/build_package.py testé dans test_publish.py) : ces tests
vérifient l'assemblage (imsmanifest.xml, index.html en FORGE_OFFLINE,
JS hors ligne présent), jamais un vrai import dans un LMS réel (voir le
plan — validation ADL SCORM Cloud/Moodle hors du champ de la suite
automatisée)."""
import zipfile
from tests.test_auth import anon_client # noqa: F401
def test_export_scorm_route_returns_a_zip_attachment(client, game):
resp = client.post(f"/game/{game}/export-scorm")
assert resp.status_code == 200
assert resp.headers["Content-Type"] == "application/zip"
assert "attachment" in resp.headers["Content-Disposition"]
assert resp.data[:2] == b"PK"
def test_export_scorm_zip_contains_the_expected_files(client, game):
resp = client.post(f"/game/{game}/export-scorm")
import io
with zipfile.ZipFile(io.BytesIO(resp.data)) as zf:
names = set(zf.namelist())
for expected in [
"imsmanifest.xml", "index.html",
"static/style.css", "static/csrf_fetch.js",
"static/js/play/actions.js", "static/js/play/bindings.js",
"static/js/play/offline/full-payload.js",
"static/js/play/offline/apply-actions.js",
"static/js/play/offline/scorm-api.js",
]:
assert expected in names, f"{expected} manquant du paquet SCORM"
# __tests__/ (node:test) n'a rien à faire dans un paquet livré au LMS.
assert not any("__tests__" in n for n in names)
def test_export_scorm_index_html_is_offline_and_self_contained(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_OFFLINE = true" in html
# Chemins RELATIFS (static/...) — un /static/... absolu casserait un
# index.html ouvert en local (file://), sans racine de serveur.
assert 'src="static/js/play/actions.js"' in html
assert 'src="/static/' not in html
assert "let gameData = " in html
def test_export_scorm_manifest_lists_index_html_as_the_sco(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")
assert 'href="index.html"' in manifest
assert "adlcp:scormtype=\"sco\"" in manifest
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."""
from tests.test_auth import _register, _confirm_2fa, _complete_onboarding, _cleanup_project
_register(anon_client, "scormexportisolation@example.com")
_confirm_2fa(anon_client)
_complete_onboarding(anon_client)
try:
resp = anon_client.post("/game/un-projet-qui-nest-pas-le-sien/export-scorm")
assert resp.status_code == 403
finally:
_cleanup_project("scormexportisolation@example.com")