diff --git a/screens/elements/delete_element.py b/screens/elements/delete_element.py index 5aaaa178..165915d7 100644 --- a/screens/elements/delete_element.py +++ b/screens/elements/delete_element.py @@ -1,8 +1,56 @@ import db +from ..flow.ensure_flow_schema import ensure_flow_schema + + +def _collect_ids(conn, screen_id, root_id): + """Rassemble l'id de cet élément et de TOUS ses descendants — ceux + qu'une suppression en cascade (parent_id ON DELETE CASCADE, voir + ensure_schema.py) va de toute façon supprimer automatiquement en même + temps, sans passer par ce fichier. Nécessaire pour nettoyer AUSSI les + références de la Logique de la scène vers un simple DESCENDANT, pas + seulement vers l'élément explicitement supprimé.""" + rows = conn.execute( + "SELECT id, parent_id FROM _screen_elements WHERE screen_id = ?", (screen_id,) + ).fetchall() + children_of = {} + for r in rows: + if r["parent_id"]: + children_of.setdefault(r["parent_id"], []).append(r["id"]) + ids = [root_id] + stack = [root_id] + while stack: + current = stack.pop() + for child_id in children_of.get(current, []): + ids.append(child_id) + stack.append(child_id) + return ids + def delete_element(slug, element_id): + ensure_flow_schema(slug) conn = db.connect(slug) + row = conn.execute("SELECT screen_id FROM _screen_elements WHERE id = ?", (element_id,)).fetchone() + if not row: + conn.close() + return + ids = _collect_ids(conn, row["screen_id"], element_id) + placeholders = ",".join("?" * len(ids)) + # Un nœud de la Logique de la scène (déclencheur "clic sur cet + # élément"/action "Modifier cet élément"...) ou une ancienne action du + # système _actions (conservé pour compatibilité) qui référence + # l'élément supprimé OU L'UN DE SES DESCENDANTS n'a plus aucun sens une + # fois l'élément disparu — et ces colonnes (trigger_element_id/ + # target_element_id) n'ont volontairement PAS de ON DELETE CASCADE (un + # élément ne doit pas pouvoir être supprimé "par erreur" en cascade + # depuis un nœud de logique qu'on modifie). Sans ce nettoyage préalable, + # PRAGMA foreign_keys=ON (voir db/connection.py) fait échouer la + # suppression elle-même avec "FOREIGN KEY constraint failed". + conn.execute( + f"DELETE FROM _flow_nodes WHERE trigger_element_id IN ({placeholders}) OR target_element_id IN ({placeholders})", + ids + ids, + ) + conn.execute(f"DELETE FROM _actions WHERE target_element_id IN ({placeholders})", ids) conn.execute("DELETE FROM _screen_elements WHERE id = ?", (element_id,)) conn.commit() conn.close() diff --git a/tests/test_delete_element_referenced_by_flow.py b/tests/test_delete_element_referenced_by_flow.py new file mode 100644 index 00000000..da926739 --- /dev/null +++ b/tests/test_delete_element_referenced_by_flow.py @@ -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