Dev #1

Merged
w-vandal merged 62 commits from dev into main 2026-08-26 04:56:44 +00:00
2 changed files with 127 additions and 0 deletions
Showing only changes of commit 1e86077e45 - Show all commits
+48
View File
@@ -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