Corrige un plantage à la suppression d'un élément référencé par la Logique de la scène

Bug remonté : supprimer un élément plantait avec sqlite3.IntegrityError:
FOREIGN KEY constraint failed.

Cause : trigger_element_id/target_element_id (_flow_nodes, voir
ensure_flow_schema.py - un nœud "clic sur cet élément" ou "Modifier cet
élément"/"Activer cet onglet") et target_element_id (l'ancien système
_actions, conservé pour compatibilité) référencent _screen_elements(id)
SANS ON DELETE CASCADE - volontairement, un élément ne doit pas pouvoir
disparaître "par erreur" en cascade depuis un nœud de logique qu'on
modifie ailleurs. Mais ça veut dire que delete_element.py, qui ne
supprimait jusqu'ici que la ligne elle-même, faisait échouer PRAGMA
foreign_keys=ON (db/connection.py) dès qu'un nœud de logique existant
référençait encore l'élément.

Fix : delete_element.py nettoie maintenant ces références AVANT de
supprimer l'élément - pas seulement pour l'élément explicitement supprimé,
mais pour tous ses DESCENDANTS aussi (leur suppression est cascadée
automatiquement au niveau SQL via parent_id, sans repasser par ce
fichier, donc sans ce nettoyage si on ne le fait pas explicitement).

Ajoute tests/test_delete_element_referenced_by_flow.py (élément
référencé comme déclencheur, comme cible d'action, et cas d'un conteneur
supprimé dont un descendant est référencé).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-08-25 16:19:52 +02:00
co-authored by Claude Sonnet 5
parent 4dc42a7f3b
commit 1e86077e45
2 changed files with 127 additions and 0 deletions
@@ -0,0 +1,79 @@
"""Régression : supprimer un élément référencé par un nœud de la Logique
de la scène (comme déclencheur "clic sur cet élément", ou comme cible
d'une action "Modifier cet élément"/"Activer cet onglet") plantait avec
sqlite3.IntegrityError: FOREIGN KEY constraint failed — trigger_element_id
et target_element_id (voir screens/flow/ensure_flow_schema.py) n'ont pas
de ON DELETE CASCADE."""
import json
import re
import screens
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="conteneur"):
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_deleting_an_element_used_as_a_flow_trigger_does_not_crash(client, game):
screen_id = _create_screen(client, game)
element_id = _add_element(client, game, screen_id, "bouton")
resp = client.post(
f"/game/{game}/screens/{screen_id}/flow/nodes/add",
data=json.dumps({"node_type": "trigger", "trigger_event": "clic", "trigger_element_id": element_id}),
content_type="application/json",
)
assert resp.status_code == 200
resp = client.post(f"/game/{game}/elements/{element_id}/delete", follow_redirects=False)
assert resp.status_code == 302
assert screens.get_element(game, element_id) is None
def test_deleting_an_element_used_as_a_flow_action_target_does_not_crash(client, game):
screen_id = _create_screen(client, game)
other_element_id = _add_element(client, game, screen_id, "bouton")
target_element_id = _add_element(client, game, screen_id, "texte")
resp = client.post(
f"/game/{game}/screens/{screen_id}/flow/nodes/add",
data=json.dumps({
"node_type": "action", "action_type": "modifier_element", "target_element_id": target_element_id,
"element_property": "visibilite", "element_value": "cache",
}),
content_type="application/json",
)
assert resp.status_code == 200
resp = client.post(f"/game/{game}/elements/{target_element_id}/delete", follow_redirects=False)
assert resp.status_code == 302
assert screens.get_element(game, target_element_id) is None
# L'autre élément (non concerné) reste intact.
assert screens.get_element(game, other_element_id) is not None
def test_deleting_a_container_cleans_up_flow_references_to_its_descendants(client, game):
"""Un conteneur supprimé cascade la suppression de ses enfants au
niveau SQL (parent_id ON DELETE CASCADE) - sans passer par
delete_element.py pour chacun d'eux. Le nettoyage doit donc couvrir
aussi les DESCENDANTS, pas seulement l'élément explicitement supprimé."""
screen_id = _create_screen(client, game)
container_id = _add_element(client, game, screen_id, "conteneur")
resp = client.post(f"/game/{game}/elements/{container_id}/children/add", data={"widget": "bouton"}, follow_redirects=False)
child_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
resp = client.post(
f"/game/{game}/screens/{screen_id}/flow/nodes/add",
data=json.dumps({"node_type": "trigger", "trigger_event": "clic", "trigger_element_id": child_id}),
content_type="application/json",
)
assert resp.status_code == 200
resp = client.post(f"/game/{game}/elements/{container_id}/delete", follow_redirects=False)
assert resp.status_code == 302
assert screens.get_element(game, container_id) is None
assert screens.get_element(game, child_id) is None