Phase -1 : découpe le moteur de play.html en modules JS + premiers tests JS

templates/play.html était un unique fichier HTML+CSS+JS de 1069 lignes,
tout le moteur de jeu vivant dans UN SEUL <script>, sans aucune
couverture de test sur cette logique (seuls le rendu HTML et la syntaxe
JS étaient vérifiés). La feuille de route à venir (état par joueur,
hasard, clavier/minuteur, position/collision, son — voir le plan) va
justement faire grossir ce moteur : "un fichier = une fonction, un
dossier = une responsabilité" s'applique aussi au JS, pas seulement au
Python — le moment de découper est avant d'ajouter encore plus de code,
pas après.

Découpage en 6 fichiers sous static/js/play/, calqués sur les sections
déjà présentes dans le code (aucune réorganisation de logique, une pure
extraction) : screens.js (affichage d'écran, timeline d'animation),
conditions.js (évaluation des conditions — la partie 100% PURE, sans
DOM, la plus testable), actions.js (exécution des actions), triggers.js
(recherche des nœuds déclencheurs, attache des écouteurs), bindings.js
(résolution des {{champ}}, rafraîchissement des données), flow-engine.js
(parcours du graphe, événements personnalisés).

Zéro nouvel outillage : plusieurs <script src> dans l'ordre, partageant
le même espace global qu'avant (aucun bundler, aucune étape de build).
Les 2 URLs de route dont ces fichiers ont besoin (flow_node_run_data/
run_variable, runtime_payload) ne peuvent plus être injectées par Jinja
directement dans le code (un fichier statique n'est jamais passé par le
moteur de templates) — elles sont maintenant posées une fois dans
window.FORGE_PLAY_URLS par le petit <script> inline restant dans
play.html, qui ne porte plus que les données Jinja (gameData) et
l'amorçage (bindClicks() etc. au chargement).

publish/build_package.py : ajoute static/js/play à la liste des fichiers
copiés dans l'exécutable exporté (le mode jouable en dépend désormais).

Premiers tests JS (static/js/play/__tests__/conditions.test.js, lancés
via `node --test`, zéro nouvelle dépendance npm — decision prise avec
l'utilisateur de commencer par la logique PURE seulement, pas par une
couverture DOM via jsdom) : compareValues, resolveVariablePath,
evaluateConditionClause/Node, exactement la logique que les phases à
venir (opérations mathématiques, condition de collision) vont étendre.

tests/conftest.py : nouveau helper play_js_bundle() (concatène tout
static/js/play/*.js) — 13 tests existants qui vérifiaient la présence de
telle fonction/chaîne dans le HTML de /game/<slug>/play (tout le JS y
était inline avant ce découpage) sont mis à jour pour chercher dans ce
bundle à la place ; les tests qui vérifient un CSS/HTML réellement resté
dans play.html (forgeHighlight, forgeDisabled, #playFrame...) continuent
de chercher dans le HTML.

Vérifié : 215 tests pytest passent (aucune régression comportementale,
juste une réorganisation), 13 tests node:test passent, node --check sur
chacun des 6 nouveaux fichiers. Test manuel recommandé (jeu joué de bout
en bout : navigation, clic, survol, répéteur, condition, animation)
avant de considérer le découpage définitivement sans risque.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-08-30 13:59:11 +02:00
co-authored by Claude Sonnet 5
parent a445b72a6e
commit dbada333d5
17 changed files with 1216 additions and 1016 deletions
+20
View File
@@ -87,6 +87,26 @@ def game(client):
shutil.rmtree(game_dir)
_PLAY_JS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "static", "js", "play")
def play_js_bundle():
"""Concatène tout le JS du moteur jouable (voir le plan de
modularisation — static/js/play/, un fichier par responsabilité,
extrait de l'ancien <script> unique de templates/play.html). Plusieurs
tests vérifient que "le JS livré au joueur" contient telle fonction/
telle chaîne, sans dépendre d'un navigateur — avant le découpage, ça
revenait à chercher dans le HTML de /game/<slug>/play (tout était
inline) ; maintenant la logique vit dans ces fichiers statiques."""
parts = []
for name in sorted(os.listdir(_PLAY_JS_DIR)):
path = os.path.join(_PLAY_JS_DIR, name)
if os.path.isfile(path) and name.endswith(".js"):
with open(path, encoding="utf-8") as f:
parts.append(f.read())
return "\n".join(parts)
@pytest.fixture
def mail_object(client, game):
"""Un objet "Mail" avec deux champs texte (sujet, expediteur), pour les
+5 -2
View File
@@ -4,6 +4,8 @@ et une durée sur l'axe du temps (secondes depuis l'affichage de l'écran)."""
import json
import re
from conftest import play_js_bundle
def _create_screen(client, slug, name="Accueil"):
resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False)
@@ -98,8 +100,9 @@ def test_play_page_exposes_animation_runtime_and_animate_css(client, game):
resp = client.get(f"/game/{game}/play")
html = resp.data.decode()
assert "animate.min.css" in html
assert "runAnimationTimeline" in html
assert "injectCustomKeyframes" in html
js = play_js_bundle()
assert "runAnimationTimeline" in js
assert "injectCustomKeyframes" in js
def test_play_page_payload_includes_clip(client, game):
+8 -8
View File
@@ -10,6 +10,8 @@ vérifient les tests de non-régression ci-dessous, en plus des tests dédiés
import json
import re
from conftest import play_js_bundle
def _create_screen(client, slug, name="Accueil"):
resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False)
@@ -49,10 +51,9 @@ def test_legacy_single_clause_condition_still_works_without_cond_clauses(client,
)
node = resp.get_json()
assert node["cond_clauses"] is None
resp = client.get(f"/game/{game}/play")
html = resp.data.decode()
assert "evaluateConditionClause" in html
assert "cond_clauses" in html
js = play_js_bundle()
assert "evaluateConditionClause" in js
assert "cond_clauses" in js
def test_condition_node_persists_clauses_and_combinator(client, game):
@@ -102,7 +103,6 @@ def test_condition_combinator_defaults_to_et_when_absent(client, game):
def test_play_page_exposes_combinator_logic(client, game):
resp = client.get(f"/game/{game}/play")
html = resp.data.decode()
assert "cond_combinator" in html
assert "'ou'" in html
js = play_js_bundle()
assert "cond_combinator" in js
assert "'ou'" in js
+12 -14
View File
@@ -5,6 +5,8 @@ d'un élément après décision."""
import json
import re
from conftest import play_js_bundle
def _create_screen(client, slug, name="Accueil"):
resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False)
@@ -72,11 +74,10 @@ def test_survol_trigger_node_persists(client, game):
def test_play_page_exposes_hover_trigger_runtime(client, game):
"""bindHoverTriggers() (mouseenter/mouseleave -> runFlowFrom) doit être
exposé et appelé, exactement comme bindClicks() pour "Au clic"."""
resp = client.get(f"/game/{game}/play")
html = resp.data.decode()
assert "bindHoverTriggers" in html
assert "'survol'" in html
assert "'fin_survol'" in html
js = play_js_bundle()
assert "bindHoverTriggers" in js
assert "'survol'" in js
assert "'fin_survol'" in js
def test_modifier_element_contenu_action_persists(client, game):
@@ -107,9 +108,7 @@ def test_modifier_element_contenu_action_persists(client, game):
def test_play_page_exposes_contenu_property_runtime(client, game):
resp = client.get(f"/game/{game}/play")
html = resp.data.decode()
assert "'contenu'" in html
assert "'contenu'" in play_js_bundle()
# ---------- 3.2 — Séquences temporisées ----------
@@ -128,10 +127,9 @@ def test_attendre_action_node_persists_delay(client, game):
def test_play_page_exposes_wait_action_runtime(client, game):
resp = client.get(f"/game/{game}/play")
html = resp.data.decode()
assert "'attendre'" in html
assert "setTimeout" in html
js = play_js_bundle()
assert "'attendre'" in js
assert "setTimeout" in js
# ---------- 3.3 — Surbrillance générique dynamique ----------
@@ -157,7 +155,7 @@ def test_play_page_exposes_highlight_runtime_and_css(client, game):
resp = client.get(f"/game/{game}/play")
html = resp.data.decode()
assert "forgeHighlight" in html
assert "'surbrillance'" in html
assert "'surbrillance'" in play_js_bundle()
# ---------- 3.4 — Overlay / modale réutilisable ----------
@@ -473,4 +471,4 @@ def test_play_page_exposes_lock_runtime_and_css(client, game):
html = resp.data.decode()
assert "forgeDisabled" in html
assert "pointer-events:none" in html
assert "'desactive'" in html
assert "'desactive'" in play_js_bundle()
+9 -9
View File
@@ -2,6 +2,8 @@
import json
import re
from conftest import play_js_bundle
def _create_screen(client, slug, name="Accueil"):
resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False)
@@ -143,11 +145,10 @@ def test_play_page_exposes_row_bindings_helpers(client, game):
résoudre les {{champ}} de la ligne ouverte (voir README, section
"Cliquer une ligne de Répéteur") — vérifie que le JS livré au joueur
contient bien les briques attendues, sans dépendre d'un navigateur."""
resp = client.get(f"/game/{game}/play")
html = resp.data.decode()
assert "applyOpenRowBindings" in html
assert "lastClickedRowId" in html
assert "'ouvrir_ligne'" in html
js = play_js_bundle()
assert "applyOpenRowBindings" in js
assert "lastClickedRowId" in js
assert "'ouvrir_ligne'" in js
def test_repeater_filter_literal_value(client, game, mail_object):
@@ -224,7 +225,6 @@ def test_play_page_exposes_screen_show_trigger_helper(client, game):
"""Le runtime jouable doit exécuter les nœuds "affichage" à chaque fois
qu'un écran est montré (premier affichage, retour en arrière, données
modifiées) — voir README, section correspondante."""
resp = client.get(f"/game/{game}/play")
html = resp.data.decode()
assert "runScreenShowTriggers" in html
assert "'affichage'" in html
js = play_js_bundle()
assert "runScreenShowTriggers" in js
assert "'affichage'" in js
+5 -4
View File
@@ -3,6 +3,8 @@ import re
import db
from conftest import play_js_bundle
def _create_screen(client, slug, name="Accueil"):
resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False)
@@ -265,7 +267,6 @@ def test_play_page_click_binding_guards_against_duplicate_listeners(client, game
ré-attachement — un vrai test de non-régression comportementale (clics
répétés via un navigateur réel) a été exécuté manuellement pour valider
le correctif : 5 clics sur +10 -> exactement +50, jamais plus."""
resp = client.get(f"/game/{game}/play")
html = resp.data.decode()
assert "clickBound" in html
assert "if (elDiv.dataset.clickBound) return;" in html
js = play_js_bundle()
assert "clickBound" in js
assert "if (elDiv.dataset.clickBound) return;" in js
+5 -4
View File
@@ -2,6 +2,8 @@
import json
import re
from conftest import play_js_bundle
def _create_screen(client, slug, name="Accueil"):
resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False)
@@ -36,7 +38,6 @@ def test_play_page_exposes_activer_onglet_runtime(client, game):
"""Le runtime jouable doit savoir exécuter "activer_onglet" (montrer un
élément, masquer ses frères) — vérifie que le JS livré au joueur
contient bien cette logique, sans dépendre d'un navigateur."""
resp = client.get(f"/game/{game}/play")
html = resp.data.decode()
assert "'activer_onglet'" in html
assert "parentKey" in html
js = play_js_bundle()
assert "'activer_onglet'" in js
assert "parentKey" in js
+3 -1
View File
@@ -16,6 +16,8 @@
ne se déforment pas selon la fenêtre du joueur."""
import re
from conftest import play_js_bundle
def _create_screen(client, slug, name="Accueil"):
resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False)
@@ -137,4 +139,4 @@ def test_play_payload_includes_preview_aspect_per_screen(client, game):
def test_play_page_has_letterboxing_frame(client, game):
html = client.get(f"/game/{game}/play").data.decode()
assert 'id="playFrame"' in html
assert "applyScreenAspect" in html
assert "applyScreenAspect" in play_js_bundle()