Dev #1
@@ -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()
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user