Files
Forge-Engine/tests/test_animations.py
T
williamandClaude Sonnet 5 dbada333d5 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>
2026-08-30 13:59:11 +02:00

119 lines
4.7 KiB
Python

"""Tests de la timeline d'animation : clips Animate.css ou personnalisés
(images-clés), posés sur la piste d'un élément, avec un instant de départ
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)
return int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1))
def _add_element(client, slug, screen_id, widget):
resp = client.post(f"/game/{slug}/screens/{screen_id}/elements/add", data={"widget": widget}, follow_redirects=False)
return int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
def test_animate_css_clip_persists(client, game):
screen_id = _create_screen(client, game)
el_id = _add_element(client, game, screen_id, "titre")
resp = client.post(
f"/game/{game}/screens/{screen_id}/animations/clips/add",
data=json.dumps({
"element_id": el_id, "kind": "animate_css", "animate_name": "bounce",
"start_time": "1.5", "duration": "2", "delay": "0", "easing": "ease", "iteration_count": "1",
}),
content_type="application/json",
)
assert resp.status_code == 200
clip = resp.get_json()
assert clip["kind"] == "animate_css"
assert clip["animate_name"] == "bounce"
assert clip["start_time"] == 1.5
assert clip["duration"] == 2.0
assert clip["element_id"] == el_id
def test_custom_keyframes_clip_persists(client, game):
screen_id = _create_screen(client, game)
el_id = _add_element(client, game, screen_id, "conteneur")
keyframes = [{"percent": 0, "props": {"opacity": "0"}}, {"percent": 100, "props": {"opacity": "1"}}]
resp = client.post(
f"/game/{game}/screens/{screen_id}/animations/clips/add",
data=json.dumps({
"element_id": el_id, "kind": "custom", "custom_keyframes": keyframes,
"start_time": "0", "duration": "3",
}),
content_type="application/json",
)
assert resp.status_code == 200
clip = resp.get_json()
assert clip["kind"] == "custom"
assert json.loads(clip["custom_keyframes"]) == keyframes
def test_clip_update_changes_timing(client, game):
screen_id = _create_screen(client, game)
el_id = _add_element(client, game, screen_id, "bouton")
clip = client.post(
f"/game/{game}/screens/{screen_id}/animations/clips/add",
data=json.dumps({"element_id": el_id, "kind": "animate_css", "animate_name": "fadeIn"}),
content_type="application/json",
).get_json()
resp = client.post(
f"/game/{game}/animations/clips/{clip['id']}/update",
data=json.dumps({"start_time": "4", "duration": "0.5"}),
content_type="application/json",
)
assert resp.status_code == 200
updated = resp.get_json()
assert updated["start_time"] == 4.0
assert updated["duration"] == 0.5
def test_clip_delete_removes_it(client, game):
screen_id = _create_screen(client, game)
el_id = _add_element(client, game, screen_id, "bouton")
clip = client.post(
f"/game/{game}/screens/{screen_id}/animations/clips/add",
data=json.dumps({"element_id": el_id, "kind": "animate_css", "animate_name": "tada"}),
content_type="application/json",
).get_json()
resp = client.post(f"/game/{game}/animations/clips/{clip['id']}/delete")
assert resp.status_code == 200
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
assert f'"id": {clip["id"]}' not in html
def test_screen_edit_exposes_timeline_panel_and_animate_css(client, game):
screen_id = _create_screen(client, game)
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
assert "animTabPanel" in html
assert "Timeline d'animation" in html
assert "animate.min.css" in html
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
js = play_js_bundle()
assert "runAnimationTimeline" in js
assert "injectCustomKeyframes" in js
def test_play_page_payload_includes_clip(client, game):
screen_id = _create_screen(client, game)
el_id = _add_element(client, game, screen_id, "titre")
client.post(
f"/game/{game}/screens/{screen_id}/animations/clips/add",
data=json.dumps({"element_id": el_id, "kind": "animate_css", "animate_name": "pulse", "start_time": "2"}),
content_type="application/json",
)
html = client.get(f"/game/{game}/play").data.decode()
assert '"animate_name": "pulse"' in html
assert '"start_time": 2.0' in html