Fix éditeur de scène 2D : décalage visuel des objets + crash Timeline
Deux bugs remontés en test manuel sur l'éditeur de scène (Phase A,
commit f0070fa) :
1. Décalage visuel à l'ajout d'un objet de scène (personnage/décor) :
render_scene_object.py positionne son <img> en absolu (left/top/
width/height en px), pensé pour être un enfant DIRECT de
.playScreen.playScene en mode jouable. Dans scene_edit.html, la même
balise est nichée dans .canvasElementInner, lui-même déjà positionné
par .canvasElement — l'image se repositionnait donc EN PLUS depuis
#canvas (son ancêtre positionné le plus proche), en double du
décalage déjà appliqué par le conteneur. Corrigé par une règle CSS
scoped à l'éditeur (.canvasElementInner > img) qui neutralise le
positionnement propre de l'image et la fait simplement remplir son
conteneur.
2. "FOREIGN KEY constraint failed" à l'ajout d'un clip de Timeline sur
un objet de scène : _animation_clips.element_id portait une VRAIE
contrainte FK vers _screen_elements depuis la création de la table.
animation-timeline.js est réutilisé TEL QUEL entre les deux éditeurs
(voir le plan "Fondations d'une plateforme multi-éditeurs") et
n'opère aucune distinction — pour un jeu jeu_2d, element_id désigne
en réalité un id de _scene_objects, absent de _screen_elements, d'où
l'échec de l'INSERT sous PRAGMA foreign_keys=ON. ensure_animation_schema
reconstruit maintenant la table (une fois, migration automatique à la
volée comme le reste du schéma) sans cette contrainte FK — même
patron que trigger_element_id/cond_element_a dans ensure_flow_schema.py.
Comme la suppression en cascade reposait jusqu'ici sur cette FK, un
nettoyage manuel des clips a été ajouté à la suppression d'un élément
(delete_element.py) et d'un objet de scène (delete_scene_object.py).
Nouveaux tests (tests/test_scene_edit_view.py, +4 cas) : ajout d'un
clip de Timeline sur un objet de scène via la route, nettoyage des
clips à la suppression d'un objet de scène et d'un élément DOM. Suite
complète : 312 tests passent (aucune régression).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
f0070faced
commit
43abfc9b93
@@ -1,18 +1,10 @@
|
|||||||
import db
|
import db
|
||||||
|
|
||||||
|
_CREATE_TABLE = """
|
||||||
def ensure_animation_schema(slug):
|
CREATE TABLE _animation_clips (
|
||||||
"""Crée la table des clips d'animation si elle n'existe pas encore —
|
|
||||||
même principe que ensure_flow_schema.py : un jeu créé avant l'ajout de
|
|
||||||
cette fonctionnalité n'a pas besoin d'être migré à la main, la première
|
|
||||||
ouverture du panneau timeline suffit."""
|
|
||||||
conn = db.connect(slug)
|
|
||||||
conn.executescript(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS _animation_clips (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
screen_id INTEGER NOT NULL REFERENCES _screens(id) ON DELETE CASCADE,
|
screen_id INTEGER NOT NULL REFERENCES _screens(id) ON DELETE CASCADE,
|
||||||
element_id INTEGER NOT NULL REFERENCES _screen_elements(id) ON DELETE CASCADE,
|
element_id INTEGER NOT NULL,
|
||||||
kind TEXT NOT NULL DEFAULT 'animate_css',
|
kind TEXT NOT NULL DEFAULT 'animate_css',
|
||||||
animate_name TEXT,
|
animate_name TEXT,
|
||||||
custom_keyframes TEXT,
|
custom_keyframes TEXT,
|
||||||
@@ -24,6 +16,48 @@ def ensure_animation_schema(slug):
|
|||||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_animation_schema(slug):
|
||||||
|
"""Crée la table des clips d'animation si elle n'existe pas encore —
|
||||||
|
même principe que ensure_flow_schema.py : un jeu créé avant l'ajout de
|
||||||
|
cette fonctionnalité n'a pas besoin d'être migré à la main, la première
|
||||||
|
ouverture du panneau timeline suffit.
|
||||||
|
|
||||||
|
element_id désigne, selon game_type, un _screen_elements (éditeur
|
||||||
|
document) OU un _scene_objects (éditeur jeu_2d) — animation-timeline.js
|
||||||
|
est réutilisé TEL QUEL entre les deux éditeurs (voir le plan "Fondations
|
||||||
|
d'une plateforme multi-éditeurs") et ne fait aucune distinction. Pas de
|
||||||
|
contrainte FK vers _screen_elements : une scène jeu_2d n'a pas de ligne
|
||||||
|
correspondante dans cette table, ce qui ferait échouer l'insertion avec
|
||||||
|
PRAGMA foreign_keys=ON (voir db/connection.py). Le nettoyage à la
|
||||||
|
suppression d'un élément/objet est fait manuellement (voir
|
||||||
|
screens/elements/delete_element.py et screens/scenes/delete_scene_object.py),
|
||||||
|
même patron que trigger_element_id/cond_element_a dans
|
||||||
|
ensure_flow_schema.py."""
|
||||||
|
conn = db.connect(slug)
|
||||||
|
cols = {r["name"]: r for r in conn.execute("PRAGMA table_info(_animation_clips)").fetchall()}
|
||||||
|
if not cols:
|
||||||
|
conn.executescript(_CREATE_TABLE)
|
||||||
|
elif cols["element_id"]["notnull"] and _has_fk_on_element_id(conn):
|
||||||
|
conn.executescript(
|
||||||
|
_CREATE_TABLE.replace("CREATE TABLE _animation_clips", "CREATE TABLE _animation_clips_new")
|
||||||
)
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO _animation_clips_new
|
||||||
|
(id, screen_id, element_id, kind, animate_name, custom_keyframes,
|
||||||
|
start_time, duration, delay, easing, iteration_count, created_at)
|
||||||
|
SELECT id, screen_id, element_id, kind, animate_name, custom_keyframes,
|
||||||
|
start_time, duration, delay, easing, iteration_count, created_at
|
||||||
|
FROM _animation_clips"""
|
||||||
|
)
|
||||||
|
conn.execute("DROP TABLE _animation_clips")
|
||||||
|
conn.execute("ALTER TABLE _animation_clips_new RENAME TO _animation_clips")
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _has_fk_on_element_id(conn):
|
||||||
|
return any(
|
||||||
|
fk["from"] == "element_id" for fk in conn.execute("PRAGMA foreign_key_list(_animation_clips)").fetchall()
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import db
|
import db
|
||||||
|
|
||||||
|
from ..animations.ensure_animation_schema import ensure_animation_schema
|
||||||
from ..flow.ensure_flow_schema import ensure_flow_schema
|
from ..flow.ensure_flow_schema import ensure_flow_schema
|
||||||
from .element_descendant_ids import element_descendant_ids
|
from .element_descendant_ids import element_descendant_ids
|
||||||
|
|
||||||
|
|
||||||
def delete_element(slug, element_id):
|
def delete_element(slug, element_id):
|
||||||
ensure_flow_schema(slug)
|
ensure_flow_schema(slug)
|
||||||
|
ensure_animation_schema(slug)
|
||||||
conn = db.connect(slug)
|
conn = db.connect(slug)
|
||||||
row = conn.execute("SELECT screen_id FROM _screen_elements WHERE id = ?", (element_id,)).fetchone()
|
row = conn.execute("SELECT screen_id FROM _screen_elements WHERE id = ?", (element_id,)).fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
@@ -39,6 +41,10 @@ def delete_element(slug, element_id):
|
|||||||
ids + ids + ids + ids,
|
ids + ids + ids + ids,
|
||||||
)
|
)
|
||||||
conn.execute(f"DELETE FROM _actions WHERE target_element_id IN ({placeholders})", ids)
|
conn.execute(f"DELETE FROM _actions WHERE target_element_id IN ({placeholders})", ids)
|
||||||
|
# _animation_clips.element_id n'a plus de contrainte FK (voir
|
||||||
|
# ensure_animation_schema.py — réutilisé aussi par les objets de scène
|
||||||
|
# jeu_2d) : nettoyage manuel, même raison que ci-dessus.
|
||||||
|
conn.execute(f"DELETE FROM _animation_clips WHERE element_id IN ({placeholders})", ids)
|
||||||
conn.execute("DELETE FROM _screen_elements WHERE id = ?", (element_id,))
|
conn.execute("DELETE FROM _screen_elements WHERE id = ?", (element_id,))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import db
|
import db
|
||||||
|
|
||||||
from .ensure_scene_schema import ensure_scene_schema
|
from .ensure_scene_schema import ensure_scene_schema
|
||||||
|
from ..animations.ensure_animation_schema import ensure_animation_schema
|
||||||
from ..flow.ensure_flow_schema import ensure_flow_schema
|
from ..flow.ensure_flow_schema import ensure_flow_schema
|
||||||
|
|
||||||
|
|
||||||
@@ -10,15 +11,19 @@ def delete_scene_object(slug, object_id):
|
|||||||
cond_element_b — aucun n'a de contrainte FK, voir
|
cond_element_b — aucun n'a de contrainte FK, voir
|
||||||
ensure_flow_schema.py) : un nœud qui vise un objet supprimé n'a plus
|
ensure_flow_schema.py) : un nœud qui vise un objet supprimé n'a plus
|
||||||
de sens, même patron que screens/elements/delete_element.py pour le
|
de sens, même patron que screens/elements/delete_element.py pour le
|
||||||
DOM."""
|
DOM. Nettoie aussi ses clips d'animation (_animation_clips.element_id,
|
||||||
|
voir ensure_animation_schema.py — réutilisé tel quel par
|
||||||
|
animation-timeline.js pour les deux éditeurs, sans contrainte FK)."""
|
||||||
ensure_scene_schema(slug)
|
ensure_scene_schema(slug)
|
||||||
ensure_flow_schema(slug)
|
ensure_flow_schema(slug)
|
||||||
|
ensure_animation_schema(slug)
|
||||||
conn = db.connect(slug)
|
conn = db.connect(slug)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""DELETE FROM _flow_nodes WHERE trigger_object_id = ? OR target_object_id = ?
|
"""DELETE FROM _flow_nodes WHERE trigger_object_id = ? OR target_object_id = ?
|
||||||
OR cond_element_a = ? OR cond_element_b = ?""",
|
OR cond_element_a = ? OR cond_element_b = ?""",
|
||||||
(object_id, object_id, object_id, object_id),
|
(object_id, object_id, object_id, object_id),
|
||||||
)
|
)
|
||||||
|
conn.execute("DELETE FROM _animation_clips WHERE element_id = ?", (object_id,))
|
||||||
conn.execute("DELETE FROM _scene_objects WHERE id = ?", (object_id,))
|
conn.execute("DELETE FROM _scene_objects WHERE id = ?", (object_id,))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|||||||
@@ -5,6 +5,17 @@
|
|||||||
{% block extra_head %}
|
{% block extra_head %}
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/fonts/fonts.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/fonts/fonts.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/animate.min.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/animate.min.css') }}">
|
||||||
|
<style>
|
||||||
|
/* render_scene_object.py positionne son <img> en absolu (left/top/width/
|
||||||
|
height en px) pour s'afficher correctement en enfant DIRECT de
|
||||||
|
.playScreen.playScene (voir templates/play.html) — dans le canevas de
|
||||||
|
CET éditeur, cette même balise est nichée dans .canvasElementInner,
|
||||||
|
lui-même déjà positionné/dimensionné par .canvasElement (voir plus
|
||||||
|
bas) : sans ce correctif, l'image se positionnait EN PLUS depuis
|
||||||
|
#canvas (son ancêtre positionné le plus proche), en double du
|
||||||
|
décalage déjà appliqué par .canvasElement. */
|
||||||
|
.canvasElementInner > img{ position:static !important; left:auto !important; top:auto !important; width:100% !important; height:100% !important; z-index:auto !important; }
|
||||||
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<!-- Fondations multi-éditeurs — éditeur de scène 2D (jeux "jeu_2d", voir
|
<!-- Fondations multi-éditeurs — éditeur de scène 2D (jeux "jeu_2d", voir
|
||||||
|
|||||||
@@ -131,3 +131,42 @@ def test_screen_edit_js_bundle_contains_scene_flow_helpers():
|
|||||||
js = screen_edit_js_bundle()
|
js = screen_edit_js_bundle()
|
||||||
assert "FLOW_TARGETS_OBJECTS" in js
|
assert "FLOW_TARGETS_OBJECTS" in js
|
||||||
assert "toggleFlowObjectPropertyValue" in js
|
assert "toggleFlowObjectPropertyValue" in js
|
||||||
|
|
||||||
|
|
||||||
|
def test_animation_clip_add_route_accepts_a_scene_object(client, tmp_game_slug_cleanup):
|
||||||
|
"""Bug remonté par l'utilisateur : _animation_clips.element_id portait
|
||||||
|
une VRAIE contrainte FK vers _screen_elements — poser un clip de
|
||||||
|
Timeline sur un objet de scène (_scene_objects, un id space distinct)
|
||||||
|
faisait échouer l'insertion avec "FOREIGN KEY constraint failed"."""
|
||||||
|
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||||
|
obj_id = screens.add_scene_object(slug, screen_id)
|
||||||
|
resp = client.post(
|
||||||
|
f"/game/{slug}/screens/{screen_id}/animations/clips/add",
|
||||||
|
data=json.dumps({"element_id": obj_id, "kind": "animate_css", "animate_name": "fadeIn"}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
clip = resp.get_json()
|
||||||
|
assert clip["element_id"] == obj_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_deleting_scene_object_cleans_up_its_animation_clips(client, tmp_game_slug_cleanup):
|
||||||
|
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||||
|
obj_id = screens.add_scene_object(slug, screen_id)
|
||||||
|
screens.add_animation_clip(slug, screen_id, obj_id, kind="animate_css", animate_name="fadeIn")
|
||||||
|
screens.delete_scene_object(slug, obj_id)
|
||||||
|
assert screens.list_animation_clips(slug, screen_id) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_deleting_element_cleans_up_its_animation_clips(client, game):
|
||||||
|
resp = client.post(f"/game/{game}/screens/new", data={"name": "Accueil"}, follow_redirects=False)
|
||||||
|
screen_id = int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1))
|
||||||
|
el_resp = client.post(
|
||||||
|
f"/game/{game}/screens/{screen_id}/elements/add",
|
||||||
|
data={"widget": "texte"},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
element_id = int(re.search(r"selected=(\d+)", el_resp.headers["Location"]).group(1))
|
||||||
|
screens.add_animation_clip(game, screen_id, element_id, kind="animate_css", animate_name="fadeIn")
|
||||||
|
screens.delete_element(game, element_id)
|
||||||
|
assert screens.list_animation_clips(game, screen_id) == []
|
||||||
|
|||||||
Reference in New Issue
Block a user