From 169b720620895ba6e1333874732c8b0079152b4b Mon Sep 17 00:00:00 2001 From: william Date: Thu, 27 Aug 2026 17:31:58 +0200 Subject: [PATCH 1/7] =?UTF-8?q?Corrige=20la=20sauvegarde=20des=20donn?= =?UTF-8?q?=C3=A9es=20d'un=20objet=20:=20collision=20d'id=20de=20formulair?= =?UTF-8?q?e=20entre=20objets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug rapporté : modifier une donnée dans l'onglet "Données" d'un objet redirigeait vers le panneau d'un AUTRE objet (le premier de la liste) et n'enregistrait rien sur le bon objet. Cause : chaque objet a sa propre table SQLite (db/rows/insert_row.py), donc les ids de ses lignes repartent de 1 - deux objets ont chacun une ligne #1, #2, etc. Or game_dashboard.html générait les formulaires d'édition/ suppression d'une ligne avec un id DOM basé seulement sur r.id ("dataEditForm{{r.id}}"), jamais sur l'objet auquel elle appartient. Les de DEUX objets différents pointaient donc vers le même id de formulaire dupliqué dans le document - et un id HTML dupliqué se résout vers le PREMIER élément trouvé (le premier objet listé), pas celui réellement affiché sous les yeux de l'utilisateur. Correctif : les ids de formulaires ("dataEditForm"/"dataDeleteForm") et les attributs form="..." des champs sont maintenant scopés par objet ET par ligne ("dataEditForm{{d.id}}-{{r.id}}"), comme c'était déjà le cas pour les panneaux (objectEditPanel, addEntryPanel...) via data-definition-id. Nouveau test (tests/test_data_form_id_collision.py) : reproduit le scénario exact (deux objets ayant chacun une ligne #1) et vérifie que les ids de formulaire sont bien distincts et que la modification du second objet ne touche pas le premier - confirmé en échec sur l'ancien code (git stash) puis au vert avec le correctif. 128 tests au vert au total. Co-Authored-By: Claude Sonnet 5 --- templates/game_dashboard.html | 20 ++++++------- tests/test_data_form_id_collision.py | 42 ++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 10 deletions(-) create mode 100644 tests/test_data_form_id_collision.py diff --git a/templates/game_dashboard.html b/templates/game_dashboard.html index 1fce0814..b4b3842c 100644 --- a/templates/game_dashboard.html +++ b/templates/game_dashboard.html @@ -453,8 +453,8 @@ {% set rows = rows_by_definition[d.id] %} {% if rows %} {% for r in rows %} -
-
+
{% endfor %}
@@ -470,18 +470,18 @@ {% set col = f.name|colname %} {% if f.type == 'texte' %} - + {% elif f.type == 'texte_long' %} - + {% elif f.type in ('nombre_entier', 'nombre_decimal') %} - + {% elif f.type == 'booleen' %} - + {% elif f.type == 'date' %} - + {% elif f.type == 'relation' %}
- {% for opt in relation_options_by_definition[d.id].get(f.id, []) %} @@ -493,8 +493,8 @@ {% endfor %} - - + + {% endfor %} diff --git a/tests/test_data_form_id_collision.py b/tests/test_data_form_id_collision.py new file mode 100644 index 00000000..c799ae58 --- /dev/null +++ b/tests/test_data_form_id_collision.py @@ -0,0 +1,42 @@ +"""Régression : chaque objet a sa propre table SQLite, donc ses ids de +ligne repartent de 1 (db/rows/insert_row.py) — les
de l'onglet Données (game_dashboard.html) doivent donc être +scopés par objet (dataEditForm{{d.id}}-{{r.id}}), sinon deux objets ayant +chacun une ligne #1 partagent le même id de formulaire dans le DOM et le +navigateur soumet vers le premier objet trouvé, pas celui qu'on modifie +réellement.""" +import re + + +def _create_object(client, slug, name, field_name, field_type="texte"): + resp = client.post(f"/game/{slug}/objects/new", data={ + "object_name": name, "field_name[]": [field_name], "field_type[]": [field_type], + "field_relation[]": [""], "field_required[]": ["0"], "field_min[]": [""], "field_max[]": [""], + }, follow_redirects=False) + return int(resp.headers["Location"].rstrip("/").split("/")[-1]) + + +def test_data_edit_form_ids_are_scoped_per_object(client, game): + def_a = _create_object(client, game, "Jauge", "valeur", "nombre_entier") + def_b = _create_object(client, game, "Autre", "nom", "texte") + + # Chaque objet a sa PROPRE table -> sa première ligne a id=1 dans les + # deux cas, exactement le scénario qui provoquait la collision. + client.post(f"/game/{game}/objects/{def_a}/data/new", data={"valeur": "10"}) + client.post(f"/game/{game}/objects/{def_b}/data/new", data={"nom": "Bob"}) + + html = client.get(f"/game/{game}?tab=objects").get_data(as_text=True) + assert f'id="dataEditForm{def_a}-1"' in html + assert f'id="dataEditForm{def_b}-1"' in html + # Les deux formulaires doivent avoir des ids DISTINCTS malgré le même row id. + assert html.count('id="dataEditForm') == len(set(re.findall(r'id="(dataEditForm[^"]+)"', html))) + + # Modifier la donnée du DEUXIÈME objet doit bien mettre à jour def_b, + # sans toucher def_a. + client.post(f"/game/{game}/objects/{def_b}/data/1/edit", data={"nom": "Alice"}) + + import db + row_a = db.list_rows(game, db.get_definition(game, def_a))[0] + row_b = db.list_rows(game, db.get_definition(game, def_b))[0] + assert row_a["valeur"] == 10 + assert row_b["nom"] == "Alice" -- 2.54.0 From 28d8cd8cd0037b0323af1a47a90168a42d40e8dd Mon Sep 17 00:00:00 2001 From: william Date: Thu, 27 Aug 2026 17:59:21 +0200 Subject: [PATCH 2/7] =?UTF-8?q?Ajoute=20un=20script=20de=20d=C3=A9mo=20pou?= =?UTF-8?q?r=20tester=20dialogues/surbrillance/message=20diff=C3=A9r=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les trois mécanismes de guidage du joueur discutés (boîte de dialogue en popup, réaction à un clic, mise en évidence d'un élément à cliquer) existent DÉJÀ dans le moteur (voir README, section "Survol, séquences temporisées, surbrillance, overlay, verrouillage") : widget "Superposition / boîte de dialogue", action "Modifier un élément → Surbrillance", et déclencheur "affichage" combiné à l'action "Attendre" pour un message qui arrive tout seul après quelques secondes. Rien à coder côté moteur. scripts/build_demo_dialogues.py construit, via les VRAIES routes Flask (mêmes routes qu'utilise le navigateur), un jeu de démo persistant ("Demo Dialogues Surbrillance") avec deux écrans : - "Tutoriel" : dialogue de bienvenue à l'ouverture de l'écran -> son bouton OK ferme le dialogue et met un autre bouton en surbrillance -> cliquer ce bouton l'éteint, le désactive (verrouillage anti-reclic) et ouvre un dialogue "Bravo" en réaction, fermable à son tour. - "Message différé" : un dialogue "mentor" apparaît tout seul 3 secondes après l'affichage de l'écran (affichage -> attendre -> visibilité), sans aucune action du joueur. Vérifié via /runtime-payload (nombre de nœuds/arêtes attendu sur les deux écrans) et /play (overlay + surbrillance + attente bien exposés côté rendu). 128 tests toujours au vert (script indépendant, aucun changement moteur). Co-Authored-By: Claude Sonnet 5 --- scripts/build_demo_dialogues.py | 177 ++++++++++++++++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 scripts/build_demo_dialogues.py diff --git a/scripts/build_demo_dialogues.py b/scripts/build_demo_dialogues.py new file mode 100644 index 00000000..64d4e7dd --- /dev/null +++ b/scripts/build_demo_dialogues.py @@ -0,0 +1,177 @@ +"""Crée un jeu de démo "Démo — Dialogues & Surbrillance" qui exerce, bout +en bout, les trois mécanismes de guidage du joueur déjà présents dans le +moteur (voir README, section "Survol, séquences temporisées, surbrillance, +overlay, verrouillage") : + + - Écran 1 "Tutoriel" : une boîte de dialogue (widget "superposition") + s'ouvre TOUTE SEULE à l'affichage de l'écran (déclencheur "affichage"), + accueille le joueur et lui indique quoi faire. Son bouton "OK" la + referme ET met un autre bouton en surbrillance (liseré clignotant) pour + guider le clic suivant. Cliquer ce bouton met fin à la surbrillance, le + désactive (grisé, ne peut plus être recliqué) et ouvre une DEUXIÈME + boîte de dialogue "Bravo" en réaction au clic — fermable à son tour. + - Écran 2 "Message différé" : une boîte de dialogue "arrive toute seule" + 3 secondes après l'affichage de l'écran (déclencheur "affichage" -> + action "attendre" -> action "Modifier un élément → Visibilité"), sans + aucune action du joueur — utile pour un message de mentor/notification. + +Ce script utilise le VRAI client de test Flask contre les VRAIES routes de +l'appli (mêmes routes que celles appelées par le navigateur), et crée un +jeu PERSISTANT sous projects/ (contrairement aux tests pytest, il n'est +PAS supprimé à la fin) — pensé pour être ouvert et joué dans le navigateur +après coup. + +Usage : python3 scripts/build_demo_dialogues.py +""" +import json +import re +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from core.flask_app import app +from core import jinja_filters # noqa: F401 - enregistre les filtres Jinja +import routes # noqa: F401 - enregistre toutes les routes + + +def _redirect_id(resp, pattern): + return int(re.search(pattern, resp.headers["Location"]).group(1)) + + +def _create_screen(client, slug, name): + resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False) + return _redirect_id(resp, r"/screens/(\d+)/edit") + + +def _add_element(client, slug, screen_id, widget): + resp = client.post(f"/game/{slug}/screens/{screen_id}/elements/add", data={"widget": widget}, follow_redirects=False) + return _redirect_id(resp, r"selected=(\d+)") + + +def _add_child(client, slug, parent_id, widget): + resp = client.post(f"/game/{slug}/elements/{parent_id}/children/add", data={"widget": widget}, follow_redirects=False) + return _redirect_id(resp, r"selected=(\d+)") + + +def _save(client, slug, element_id, **ctrl): + data = {f"ctrl_{k}": v for k, v in ctrl.items()} + client.post(f"/game/{slug}/elements/{element_id}/save", data=data) + + +def _add_node(client, slug, screen_id, **fields): + resp = client.post( + f"/game/{slug}/screens/{screen_id}/flow/nodes/add", + data=json.dumps({**fields}), + content_type="application/json", + ) + assert resp.status_code == 200, resp.get_data(as_text=True) + return resp.get_json()["id"] + + +def _add_edge(client, slug, screen_id, from_id, to_id, from_port="out"): + resp = client.post( + f"/game/{slug}/screens/{screen_id}/flow/edges/add", + data=json.dumps({"from_node_id": from_id, "from_port": from_port, "to_node_id": to_id}), + content_type="application/json", + ) + assert resp.status_code == 200, resp.get_data(as_text=True) + + +def _modifier_element(client, slug, screen_id, target_id, prop, value): + return _add_node( + client, slug, screen_id, + node_type="action", action_type="modifier_element", + target_element_id=target_id, element_property=prop, element_value=value, + ) + + +def build(): + app.config["TESTING"] = True + with app.test_client() as client: + resp = client.post("/games/new", data={"name": "Demo Dialogues Surbrillance"}, follow_redirects=False) + assert resp.status_code == 302, resp.get_data(as_text=True) + slug = resp.headers["Location"].rstrip("/").split("/")[-1] + print(f"Jeu créé : slug={slug}") + + # ---------------- Écran 1 : Tutoriel ---------------- + screen1 = _create_screen(client, slug, "Tutoriel") + + overlay_bienvenue = _add_element(client, slug, screen1, "superposition") + _save(client, slug, overlay_bienvenue, couleur_boite="#20263a", arrondi="16") + t1 = _add_child(client, slug, overlay_bienvenue, "titre") + _save(client, slug, t1, content="Bienvenue !") + t2 = _add_child(client, slug, overlay_bienvenue, "texte") + _save(client, slug, t2, content="Ce petit tutoriel te montre comment fonctionnent les dialogues et la surbrillance. Clique sur OK pour commencer.") + btn_ok = _add_child(client, slug, overlay_bienvenue, "bouton") + _save(client, slug, btn_ok, content="OK, c'est parti") + + btn_target = _add_element(client, slug, screen1, "bouton") + _save(client, slug, btn_target, content="Clique-moi !") + + overlay_bravo = _add_element(client, slug, screen1, "superposition") + _save(client, slug, overlay_bravo, couleur_boite="#1f3a24", arrondi="16") + t3 = _add_child(client, slug, overlay_bravo, "titre") + _save(client, slug, t3, content="Bravo !") + t4 = _add_child(client, slug, overlay_bravo, "texte") + _save(client, slug, t4, content="Tu as cliqué le bon bouton. Le tutoriel est terminé.") + btn_close_bravo = _add_child(client, slug, overlay_bravo, "bouton") + _save(client, slug, btn_close_bravo, content="Fermer") + + # Déclencheur "affichage" (écran entier) -> ouvre le dialogue de bienvenue. + trig_affichage = _add_node(client, slug, screen1, node_type="trigger", trigger_event="affichage") + act_open_bienvenue = _modifier_element(client, slug, screen1, overlay_bienvenue, "visibilite", "visible") + _add_edge(client, slug, screen1, trig_affichage, act_open_bienvenue) + + # Clic sur "OK" -> ferme le dialogue de bienvenue ET met le bouton cible en surbrillance. + trig_ok = _add_node(client, slug, screen1, node_type="trigger", trigger_element_id=btn_ok, trigger_event="clic") + act_close_bienvenue = _modifier_element(client, slug, screen1, overlay_bienvenue, "visibilite", "masque") + act_highlight_on = _modifier_element(client, slug, screen1, btn_target, "surbrillance", "on") + _add_edge(client, slug, screen1, trig_ok, act_close_bienvenue) + _add_edge(client, slug, screen1, trig_ok, act_highlight_on) + + # Clic sur le bouton cible -> arrête la surbrillance, le désactive, ouvre "Bravo". + trig_target = _add_node(client, slug, screen1, node_type="trigger", trigger_element_id=btn_target, trigger_event="clic") + act_highlight_off = _modifier_element(client, slug, screen1, btn_target, "surbrillance", "off") + act_lock_target = _modifier_element(client, slug, screen1, btn_target, "desactive", "on") + act_open_bravo = _modifier_element(client, slug, screen1, overlay_bravo, "visibilite", "visible") + _add_edge(client, slug, screen1, trig_target, act_highlight_off) + _add_edge(client, slug, screen1, trig_target, act_lock_target) + _add_edge(client, slug, screen1, trig_target, act_open_bravo) + + # Clic sur "Fermer" (dans "Bravo") -> referme "Bravo". + trig_close_bravo = _add_node(client, slug, screen1, node_type="trigger", trigger_element_id=btn_close_bravo, trigger_event="clic") + act_close_bravo = _modifier_element(client, slug, screen1, overlay_bravo, "visibilite", "masque") + _add_edge(client, slug, screen1, trig_close_bravo, act_close_bravo) + + # ---------------- Écran 2 : Message différé ---------------- + screen2 = _create_screen(client, slug, "Message différé") + + overlay_mentor = _add_element(client, slug, screen2, "superposition") + _save(client, slug, overlay_mentor, couleur_boite="#2a2440", arrondi="16") + t5 = _add_child(client, slug, overlay_mentor, "titre") + _save(client, slug, t5, content="Message du mentor") + t6 = _add_child(client, slug, overlay_mentor, "texte") + _save(client, slug, t6, content="Ce message est apparu tout seul, 3 secondes après l'arrivée sur cet écran — sans aucun clic.") + btn_close_mentor = _add_child(client, slug, overlay_mentor, "bouton") + _save(client, slug, btn_close_mentor, content="Fermer") + + info2 = _add_element(client, slug, screen2, "texte") + _save(client, slug, info2, content="Attends 3 secondes sans rien faire : un message va apparaître tout seul.") + + trig_affichage2 = _add_node(client, slug, screen2, node_type="trigger", trigger_event="affichage") + act_wait = _add_node(client, slug, screen2, node_type="action", action_type="attendre", data_value="3") + act_open_mentor = _modifier_element(client, slug, screen2, overlay_mentor, "visibilite", "visible") + _add_edge(client, slug, screen2, trig_affichage2, act_wait) + _add_edge(client, slug, screen2, act_wait, act_open_mentor) + + trig_close_mentor = _add_node(client, slug, screen2, node_type="trigger", trigger_element_id=btn_close_mentor, trigger_event="clic") + act_close_mentor = _modifier_element(client, slug, screen2, overlay_mentor, "visibilite", "masque") + _add_edge(client, slug, screen2, trig_close_mentor, act_close_mentor) + + print(f"Terminé. Ouvre http://127.0.0.1:5050/game/{slug}/play pour tester.") + print(f"Éditeur : http://127.0.0.1:5050/game/{slug}/screens/{screen1}/edit") + + +if __name__ == "__main__": + build() -- 2.54.0 From 81c31a9c49c8ab38666f71c8a3d6b43076efaa5d Mon Sep 17 00:00:00 2001 From: william Date: Thu, 27 Aug 2026 18:43:29 +0200 Subject: [PATCH 3/7] =?UTF-8?q?Corrige=20la=20bo=C3=AEte=20de=20dialogue?= =?UTF-8?q?=20(superposition)=20:=20un=20=C3=A9l=C3=A9ment=20pos=C3=A9=20a?= =?UTF-8?q?pr=C3=A8s=20elle=20s'affichait=20par-dessus?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug visible sur le jeu de démo : le bouton "Clique-moi !" restait visible ET cliquable AU-DESSUS du dialogue de bienvenue censé couvrir tout l'écran, et la boîte de dialogue elle-même s'étirait bord à bord au lieu de rester une boîte centrée lisible. Cause (stacking context CSS) : le widget "superposition" ignore x/y/ width/height et pose lui-même position:fixed; inset:0; z-index:9999 sur SA PROPRE balise (render_overlay.py) — mais le cadre .playElement/ .canvasElement qui l'entoure, PARTAGÉ PAR TOUS LES WIDGETS (filters/ element_style_filter.py), continuait quand même à poser "position:absolute; z-index:" (souvent petit, ex. 1). Un élément positionné avec un z-index explicite crée un NOUVEAU contexte d'empilement CSS : le 9999 posé plus profond ne se comparait alors plus qu'AU SEIN de ce contexte, et perdait face au z-index (plus grand) d'un élément ajouté APRÈS l'overlay sur le canevas — qui s'affichait donc par-dessus le dialogue. Correctif : _element_style ne pose plus aucune position/z-index pour ce widget (position:static — sa place dans le flux est de toute façon invisible, son contenu réel étant en position:fixed). Plus de contexte d'empilement local créé à ce niveau : le z-index:9999 se compare directement à tous les autres éléments de l'écran, et gagne toujours. Profité de l'occasion pour donner à la boîte une largeur par défaut plus raisonnable (render_overlay.py : max-width:min(560px, 90%) au lieu de 90% seul) — sur un écran de jeu large, "90%" donnait une boîte étirée bord à bord peu lisible comme dialogue ; 560px reste confortable, et 90% prend toujours le relais sur un écran étroit (mobile/portrait). Nouveau test de régression (test_overlay_wrapper_does_not_trap_its_own_z_index) : confirmé en échec sur l'ancien code (git stash), au vert avec le correctif. 129 tests au vert au total. Co-Authored-By: Claude Sonnet 5 --- filters/element_style_filter.py | 19 ++++++++++++++++++- screens/rendering/render_overlay.py | 7 ++++++- tests/test_confort.py | 27 +++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/filters/element_style_filter.py b/filters/element_style_filter.py index 0f3a26c9..daf9cbe9 100644 --- a/filters/element_style_filter.py +++ b/filters/element_style_filter.py @@ -15,7 +15,24 @@ def _element_style(el): pour une animation en boucle infinie, en permanence). Voir _element_transform_style, qui porte ce "transform" sur un cadre séparé (.canvasElementInner / .playElementInner), à la taille exacte de celui-ci - (donc visuellement identique).""" + (donc visuellement identique). + + Bug corrigé : le widget "superposition" (render_overlay.py) ignore déjà + x/y/width/height et pose lui-même position:fixed; inset:0; z-index:9999 + sur SA PROPRE balise — mais tant que CE cadre-ci gardait quand même + "position:absolute; z-index:{z_index}" (le z-index de sa place dans le + canevas, souvent petit), il devenait un élément positionné avec z-index + explicite, donc un NOUVEAU contexte d'empilement CSS — le z-index:9999 + posé plus profond ne se comparait alors plus qu'AU SEIN de ce contexte, + et perdait face au z-index (plus grand) d'un frère ajouté après lui sur + le canevas, qui se retrouvait affiché PAR-DESSUS le dialogue censé tout + couvrir. En ne posant ICI aucune position/z-index pour ce widget (sa + place dans le flux normal, invisible puisque son contenu est en + position:fixed de toute façon), plus aucun contexte d'empilement n'est + créé à ce niveau : le z-index:9999 se compare alors directement aux + autres éléments de l'écran, et gagne toujours.""" + if el.get("widget") == "superposition": + return "position:static;" return "; ".join([ "position:absolute", f"left:{el['x']}%", f"top:{el['y']}%", diff --git a/screens/rendering/render_overlay.py b/screens/rendering/render_overlay.py index 1ea33d3b..cff5569d 100644 --- a/screens/rendering/render_overlay.py +++ b/screens/rendering/render_overlay.py @@ -44,7 +44,12 @@ def _render_overlay(el, meta, slug, children_map=None, ctx=None, parent_flex_dir box_style = ( f"background:{html_lib.escape(str(box_color))}; border-radius:{html_lib.escape(str(radius))}px; " - "padding:24px; max-width:90%; max-height:90%; overflow:auto; box-sizing:border-box;" + # max-width en min(...) plutôt qu'un simple pourcentage : sur un + # écran de jeu large (desktop), "90%" seul donnait une boîte étirée + # bord à bord peu lisible comme dialogue — 560px reste une largeur + # de boîte de dialogue confortable, tout en retombant sur 90% sur un + # écran de jeu étroit (mobile/portrait) pour ne jamais déborder. + "padding:24px; width:100%; max-width:min(560px, 90%); max-height:90%; overflow:auto; box-sizing:border-box;" ) child_html = _render_children(el, slug, children_map, ctx) return ( diff --git a/tests/test_confort.py b/tests/test_confort.py index 89608833..4d622083 100644 --- a/tests/test_confort.py +++ b/tests/test_confort.py @@ -181,6 +181,33 @@ def test_overlay_widget_renders_fullscreen_fixed_box(client, game): assert "forgeOverlayBox" in snippet +def test_overlay_wrapper_does_not_trap_its_own_z_index(client, game): + """Régression : le cadre .playElement/.canvasElement partagé par TOUS + les widgets (voir filters/element_style_filter.py) posait quand même + "position:absolute; z-index:" sur la + superposition, MÊME SI son propre contenu (render_overlay.py) ignore + x/y/width/height et pose déjà position:fixed + z-index:9999 lui-même. + Un élément positionné avec un z-index explicite crée un NOUVEAU contexte + d'empilement CSS : le 9999 posé plus profond ne se comparait alors plus + qu'AU SEIN de ce contexte, et perdait face au z-index (plus grand) d'un + élément normal ajouté APRÈS l'overlay sur le canevas — qui s'affichait + donc PAR-DESSUS le dialogue censé tout couvrir. Le cadre ne doit donc + plus poser aucune position/z-index pour ce widget.""" + screen_id = _create_screen(client, game) + overlay_id = _add_element(client, game, screen_id, "superposition") + # Ajouté APRÈS l'overlay -> z_index plus grand que le sien. + _add_element(client, game, screen_id, "bouton") + + resp = client.get(f"/game/{game}/play") + html = resp.data.decode() + idx = html.find(f'data-el-id="{overlay_id}"') + assert idx != -1 + tag_start = html.rfind(" Date: Thu, 27 Aug 2026 18:52:17 +0200 Subject: [PATCH 4/7] =?UTF-8?q?Corrige=20le=20texte=20illisible=20dans=20l?= =?UTF-8?q?es=20bo=C3=AEtes=20de=20dialogue=20du=20jeu=20de=20d=C3=A9mo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pas un bug moteur cette fois : le moteur ne fige JAMAIS de couleur de texte par défaut à la création d'un élément (voir default_style_for_widget.py) — un titre/texte fraîchement posé retombe donc sur le texte SOMBRE par défaut de Bulma, pensé pour un fond clair. Mon script de démo ne posait jamais explicitement de couleur de texte sur les titres/paragraphes à l'intérieur des boîtes de dialogue (fond sombre volontaire) : le texte y était donc quasi invisible, ce qui donnait l'impression trompeuse que "la boîte de dialogue est assombrie" — alors que seul son texte, invisible par défaut sur fond sombre, l'était (la boîte elle-même a bien sa couleur opaque demandée, #20263a/#1f3a24/ #2a2440, sans aucun voile supplémentaire dessus). Ajout de la couleur de texte manquante (#f5f6fa) sur chaque titre/texte posé dans une boîte de dialogue. Valeur volontairement différente de la suggestion par défaut du panneau (#e8eaf0) : save_element_controls.py ignore délibérément un réglage renvoyé identique à sa valeur par défaut tant qu'il n'a jamais été personnalisé (même logique anti-figeage que default_style_for_widget.py) — envoyer #e8eaf0 tel quel n'aurait donc eu AUCUN effet, silencieusement (piège rencontré et documenté dans le script). Jeu de démo régénéré (supprimé puis reconstruit) avec le correctif. 129 tests toujours au vert (script indépendant, aucun changement moteur). Co-Authored-By: Claude Sonnet 5 --- scripts/build_demo_dialogues.py | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/scripts/build_demo_dialogues.py b/scripts/build_demo_dialogues.py index 64d4e7dd..b65c3b88 100644 --- a/scripts/build_demo_dialogues.py +++ b/scripts/build_demo_dialogues.py @@ -21,6 +21,23 @@ jeu PERSISTANT sous projects/ (contrairement aux tests pytest, il n'est PAS supprimé à la fin) — pensé pour être ouvert et joué dans le navigateur après coup. +Couleur du texte posée explicitement (#f5f6fa) sur chaque titre/texte à +l'intérieur d'une boîte de dialogue : le moteur ne fige JAMAIS de couleur +par défaut à la création d'un élément (voir +default_style_for_widget.py — "la figer dès la création ferait apparaître +une couleur que personne n'a demandée"), un titre/texte fraîchement posé +retombe donc sur le texte sombre par défaut de Bulma, pensé pour un fond +clair — illisible sur le fond sombre d'une boîte de dialogue tant que +personne n'a choisi une couleur claire à la main dans le panneau de +propriétés. Sans ce réglage explicite ici, la démo donnait l'impression +trompeuse que "la boîte de dialogue est assombrie" (elle ne l'est pas : +seul son texte, invisible par défaut sur fond sombre, l'était). Valeur +volontairement DIFFÉRENTE de la couleur suggérée par défaut dans le +panneau (#e8eaf0) : save_element_controls.py ignore délibérément un +réglage renvoyé identique à sa valeur par défaut tant qu'il n'a jamais été +personnalisé (même logique anti-figeage), donc envoyer #e8eaf0 telle +quelle ici n'aurait eu AUCUN effet, silencieusement. + Usage : python3 scripts/build_demo_dialogues.py """ import json @@ -100,9 +117,9 @@ def build(): overlay_bienvenue = _add_element(client, slug, screen1, "superposition") _save(client, slug, overlay_bienvenue, couleur_boite="#20263a", arrondi="16") t1 = _add_child(client, slug, overlay_bienvenue, "titre") - _save(client, slug, t1, content="Bienvenue !") + _save(client, slug, t1, content="Bienvenue !", color="#f5f6fa") t2 = _add_child(client, slug, overlay_bienvenue, "texte") - _save(client, slug, t2, content="Ce petit tutoriel te montre comment fonctionnent les dialogues et la surbrillance. Clique sur OK pour commencer.") + _save(client, slug, t2, content="Ce petit tutoriel te montre comment fonctionnent les dialogues et la surbrillance. Clique sur OK pour commencer.", color="#f5f6fa") btn_ok = _add_child(client, slug, overlay_bienvenue, "bouton") _save(client, slug, btn_ok, content="OK, c'est parti") @@ -112,9 +129,9 @@ def build(): overlay_bravo = _add_element(client, slug, screen1, "superposition") _save(client, slug, overlay_bravo, couleur_boite="#1f3a24", arrondi="16") t3 = _add_child(client, slug, overlay_bravo, "titre") - _save(client, slug, t3, content="Bravo !") + _save(client, slug, t3, content="Bravo !", color="#f5f6fa") t4 = _add_child(client, slug, overlay_bravo, "texte") - _save(client, slug, t4, content="Tu as cliqué le bon bouton. Le tutoriel est terminé.") + _save(client, slug, t4, content="Tu as cliqué le bon bouton. Le tutoriel est terminé.", color="#f5f6fa") btn_close_bravo = _add_child(client, slug, overlay_bravo, "bouton") _save(client, slug, btn_close_bravo, content="Fermer") @@ -150,9 +167,9 @@ def build(): overlay_mentor = _add_element(client, slug, screen2, "superposition") _save(client, slug, overlay_mentor, couleur_boite="#2a2440", arrondi="16") t5 = _add_child(client, slug, overlay_mentor, "titre") - _save(client, slug, t5, content="Message du mentor") + _save(client, slug, t5, content="Message du mentor", color="#f5f6fa") t6 = _add_child(client, slug, overlay_mentor, "texte") - _save(client, slug, t6, content="Ce message est apparu tout seul, 3 secondes après l'arrivée sur cet écran — sans aucun clic.") + _save(client, slug, t6, content="Ce message est apparu tout seul, 3 secondes après l'arrivée sur cet écran — sans aucun clic.", color="#f5f6fa") btn_close_mentor = _add_child(client, slug, overlay_mentor, "bouton") _save(client, slug, btn_close_mentor, content="Fermer") -- 2.54.0 From 6cf4fdbe72484aa8c8dd69c69db8f222ce3551db Mon Sep 17 00:00:00 2001 From: william Date: Thu, 27 Aug 2026 19:03:29 +0200 Subject: [PATCH 5/7] =?UTF-8?q?Corrige=20la=20cr=C3=A9ation=20d'objet=20sa?= =?UTF-8?q?ns=20champ=20:=20le=20panneau=20rechargeait=20la=20page=20sans?= =?UTF-8?q?=20rien=20cr=C3=A9er?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug rapporté : le panneau "+ Nouvel objet" du tableau de bord "recharge la page sans créer d'objet". Cause : object_new exigeait "name AND fields" pour créer quoi que ce soit — or le formulaire du panneau permet de taper le nom et de cliquer directement "Créer l'objet" SANS avoir cliqué au préalable "+ Ajouter un champ" (les champs se posent typiquement APRÈS, depuis le panneau "Modifier un objet", workflow déjà supporté). Sans champ soumis, la condition échouait, la route redirigeait silencieusement vers le tableau de bord SANS créer l'objet ET sans le moindre message d'erreur — vécu comme "un rechargement qui ne fait rien". create_definition(fields=[]) fonctionne déjà très bien (crée juste une table avec id/created_at, sans colonne "métier") : retiré l'exigence d'au moins un champ, ne reste que "name" non vide. Nouveau test (tests/test_object_new_without_fields.py), confirmé en échec sur l'ancien code (git stash) puis au vert avec le correctif. 131 tests au vert au total. Co-Authored-By: Claude Sonnet 5 --- routes/objects/object_new.py | 9 ++++++++- tests/test_object_new_without_fields.py | 27 +++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 tests/test_object_new_without_fields.py diff --git a/routes/objects/object_new.py b/routes/objects/object_new.py index 2c23524a..98c0bbc0 100644 --- a/routes/objects/object_new.py +++ b/routes/objects/object_new.py @@ -15,8 +15,15 @@ def object_new(slug): externe obsolète) redirige simplement vers le tableau de bord.""" if request.method == "POST": name = request.form.get("object_name", "").strip() + # Aucun champ requis à la création : create_definition(fields=[]) + # crée simplement une table sans colonne "métier" (juste id/ + # created_at), et le panneau "Modifier un objet" (onglet Champs, + # bouton "+ Ajouter") permet d'en poser ensuite — exiger au moins + # un champ ici faisait échouer silencieusement la création (aucun + # message d'erreur, juste un retour au tableau de bord) dès qu'on + # tapait le nom sans avoir encore cliqué "+ Ajouter un champ". fields = _parse_field_rows(request.form) - if name and fields: + if name: definition_id = db.create_definition(slug, name, fields) return redirect(url_for("object_view", slug=slug, definition_id=definition_id)) diff --git a/tests/test_object_new_without_fields.py b/tests/test_object_new_without_fields.py new file mode 100644 index 00000000..944447a9 --- /dev/null +++ b/tests/test_object_new_without_fields.py @@ -0,0 +1,27 @@ +"""Régression : le panneau "+ Nouvel objet" du tableau de bord (game_dashboard.html) +soumet le formulaire dès qu'on tape un nom et clique "Créer l'objet", +SANS avoir forcément cliqué "+ Ajouter un champ" au moins une fois avant +(les champs se posent typiquement APRÈS, depuis le panneau "Modifier un +objet"). routes/objects/object_new.py exigeait pourtant au moins un champ +pour créer quoi que ce soit ("if name and fields"), sans le moindre +message d'erreur en cas d'échec — juste un aller-retour silencieux vers le +tableau de bord, vécu comme "le panneau recharge la page sans créer +d'objet".""" +import db + + +def test_object_created_with_a_name_but_no_field(client, game): + resp = client.post(f"/game/{game}/objects/new", data={"object_name": "Compteur"}, follow_redirects=False) + assert resp.status_code == 302 + assert "/objects/" in resp.headers["Location"] + + definitions = db.list_definitions(game) + assert len(definitions) == 1 + assert definitions[0]["name"] == "Compteur" + assert db.get_definition(game, definitions[0]["id"])["fields"] == [] + + +def test_object_not_created_without_a_name(client, game): + resp = client.post(f"/game/{game}/objects/new", data={"object_name": " "}, follow_redirects=False) + assert resp.status_code == 302 + assert db.list_definitions(game) == [] -- 2.54.0 From c14f7e9ba5aed0ece6fab2c57fa1f3f6eacdc5bf Mon Sep 17 00:00:00 2001 From: william Date: Thu, 27 Aug 2026 19:10:54 +0200 Subject: [PATCH 6/7] =?UTF-8?q?Corrige=20le=20vrai=20bug=20:=20un=20champ?= =?UTF-8?q?=20"Relation"=20sans=20objet=20cible=20plantait=20toute=20la=20?= =?UTF-8?q?cr=C3=A9ation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'utilisateur avait raison de contester mon précédent correctif : le problème n'était pas l'absence de champs. Reproduit précisément : créer un objet avec un champ de type "Relation vers un autre objet" SANS avoir de cible valide sélectionnée (ex. le tout premier objet créé dans un jeu — le sélecteur "Objet lié" est alors vide, faute d'un autre objet à pointer) plantait toute la requête avec une ValueError ("invalid literal for int() with base 10: ''") dans create_definition() / add_field_to_definition() (int(relation_definition_id) sans filet). Comme le crash survient APRÈS l'INSERT de la ligne _definitions mais AVANT le commit(), rien n'était jamais persisté (transaction perdue à la fermeture de la connexion) : l'objet entier disparaissait, pas seulement son champ "Relation" — d'où "le panneau recharge la page sans créer d'objet" alors que des champs avaient bien été renseignés. Correctif (routes, pas la couche db) : un champ "Relation" dont la cible n'est ni choisie ni un id valide est maintenant simplement IGNORÉ (comme une ligne sans nom, déjà le cas), dans les deux endroits qui construisent ce payload : - routes/objects/parse_field_rows.py (panneau "+ Nouvel objet") - routes/objects/object_field_add.py (panneau "+ Ajouter un champ" d'un objet déjà créé — même risque de crash dans add_field_to_definition) object_field_edit.py/update_field.py avaient déjà la bonne garde ("if relation_definition_id" avant le int()) — rien à y changer. Deux nouveaux tests, confirmés en échec sur l'ancien code (git stash, même ValueError reproduite) puis au vert avec le correctif. 133 tests au vert au total. Co-Authored-By: Claude Sonnet 5 --- routes/objects/object_field_add.py | 12 +++++-- routes/objects/parse_field_rows.py | 20 +++++++++-- tests/test_object_new_without_fields.py | 44 +++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 5 deletions(-) diff --git a/routes/objects/object_field_add.py b/routes/objects/object_field_add.py index 6fecc0e4..e383ab53 100644 --- a/routes/objects/object_field_add.py +++ b/routes/objects/object_field_add.py @@ -8,11 +8,17 @@ from core.flask_app import app @app.route("/game//objects//fields/add", methods=["POST"]) def object_field_add(slug, definition_id): name = request.form.get("field_name", "").strip() - if name: + field_type = request.form.get("field_type", "texte") + relation_definition_id = request.form.get("field_relation") + # Voir parse_field_rows.py (même garde) : "Relation" choisi sans objet + # cible valide ferait planter add_field_to_definition (int("") lève), + # ce qui annulait l'ALTER TABLE avant son commit() -- le champ + # n'était alors ajouté du tout, sans le moindre message d'erreur. + if name and not (field_type == "relation" and not (relation_definition_id or "").strip().isdigit()): db.add_field_to_definition(slug, definition_id, { "name": name, - "type": request.form.get("field_type", "texte"), - "relation_definition_id": request.form.get("field_relation"), + "type": field_type, + "relation_definition_id": relation_definition_id, "required": request.form.get("field_required") == "1", "min_value": request.form.get("field_min") or None, "max_value": request.form.get("field_max") or None, diff --git a/routes/objects/parse_field_rows.py b/routes/objects/parse_field_rows.py index c0a9b106..148aab27 100644 --- a/routes/objects/parse_field_rows.py +++ b/routes/objects/parse_field_rows.py @@ -9,10 +9,26 @@ def _parse_field_rows(form): for i, fname in enumerate(field_names): if not fname.strip(): continue + ftype = field_types[i] + relation_definition_id = field_relations[i] if i < len(field_relations) else None + if ftype == "relation" and not (relation_definition_id or "").strip().isdigit(): + # Type "Relation vers un autre objet" choisi sans (ou avant + # d'avoir pu) sélectionner d'objet cible réel — ex. le tout + # premier objet créé dans un jeu, quand ce sélecteur est encore + # vide faute d'un autre objet à pointer. create_definition() + # fait un int(relation_definition_id) sans filet : le laisser + # passer plantait TOUTE la requête (500), et comme la ligne + # _definitions n'était alors jamais validée (pas de commit() + # atteint), l'objet n'était pas créé du tout — pas de champ + # "Relation" non plus, mais un panneau qui semble "ne rien + # faire" plutôt qu'un message d'erreur clair. On ignore donc ce + # champ précis (comme une ligne sans nom, ci-dessus) plutôt que + # de faire échouer la création de TOUT l'objet à cause de lui. + continue fields.append({ "name": fname.strip(), - "type": field_types[i], - "relation_definition_id": field_relations[i] if i < len(field_relations) else None, + "type": ftype, + "relation_definition_id": relation_definition_id, "required": field_required[i] == "1" if i < len(field_required) else False, "min_value": (field_min[i].strip() or None) if i < len(field_min) else None, "max_value": (field_max[i].strip() or None) if i < len(field_max) else None, diff --git a/tests/test_object_new_without_fields.py b/tests/test_object_new_without_fields.py index 944447a9..6abe07c5 100644 --- a/tests/test_object_new_without_fields.py +++ b/tests/test_object_new_without_fields.py @@ -25,3 +25,47 @@ def test_object_not_created_without_a_name(client, game): resp = client.post(f"/game/{game}/objects/new", data={"object_name": " "}, follow_redirects=False) assert resp.status_code == 302 assert db.list_definitions(game) == [] + + +def test_relation_field_without_a_chosen_target_does_not_crash_the_whole_request(client, game): + """Régression : "Relation vers un autre objet" est TOUJOURS une option + du sélecteur de type (db/constants.py), même quand il n'existe encore + AUCUN autre objet à pointer (ex. le tout premier objet créé dans un + jeu) — le sélecteur "Objet lié" est alors vide, et + create_definition() faisait int("") sans filet, plantant TOUTE la + requête (500) avant même d'atteindre son commit() : l'objet n'était + donc PAS créé du tout, pas seulement son champ "Relation" — vécu comme + "le panneau recharge la page sans créer d'objet" alors que des champs + avaient bien été renseignés. Le champ "Relation" invalide doit être + ignoré (comme une ligne sans nom), pas faire échouer tout le reste.""" + resp = client.post(f"/game/{game}/objects/new", data={ + "object_name": "Personnage", + "field_name[]": ["nom", "arme"], + "field_type[]": ["texte", "relation"], + "field_relation[]": ["", ""], + "field_required[]": ["0", "0"], + "field_min[]": ["", ""], + "field_max[]": ["", ""], + }, follow_redirects=False) + assert resp.status_code == 302 + definitions = db.list_definitions(game) + assert len(definitions) == 1 + fields = db.get_definition(game, definitions[0]["id"])["fields"] + assert [f["name"] for f in fields] == ["nom"] + + +def test_add_relation_field_without_a_chosen_target_is_ignored_not_a_crash(client, game): + """Même garde, côté "+ Ajouter un champ" du panneau d'un objet déjà + créé (object_field_add.py / add_field_to_definition.py).""" + resp = client.post(f"/game/{game}/objects/new", data={ + "object_name": "Personnage", "field_name[]": ["nom"], "field_type[]": ["texte"], + "field_relation[]": [""], "field_required[]": ["0"], "field_min[]": [""], "field_max[]": [""], + }, follow_redirects=False) + def_id = int(resp.headers["Location"].rstrip("/").split("/")[-1]) + + resp = client.post(f"/game/{game}/objects/{def_id}/fields/add", data={ + "field_name": "arme", "field_type": "relation", "field_relation": "", + }, follow_redirects=False) + assert resp.status_code == 302 + fields = db.get_definition(game, def_id)["fields"] + assert [f["name"] for f in fields] == ["nom"] -- 2.54.0 From 5c069ae1fe2a4b13db96a5aa55baac736cba270d Mon Sep 17 00:00:00 2001 From: william Date: Thu, 27 Aug 2026 19:27:09 +0200 Subject: [PATCH 7/7] =?UTF-8?q?Corrige=20LE=20vrai=20bug=20:=20un=20nom=20?= =?UTF-8?q?de=20champ=20mot-r=C3=A9serv=C3=A9=20SQL=20(ex.=20"order")=20fa?= =?UTF-8?q?isait=20dispara=C3=AEtre=20l'objet=20entier?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reproduit à l'identique le cas signalé (objet "dialog" avec les champs order/spiker/text/level_id/parcour_id) : le champ "order" est un mot réservé SQL — "CREATE TABLE dialog (order INTEGER, ...)" plante avec "OperationalError: near \"order\": syntax error". Comme ce crash survient APRÈS l'INSERT de la ligne _definitions mais AVANT le commit(), rien n'était jamais persisté : l'objet ENTIER disparaissait, malgré des champs parfaitement remplis — d'où "j'ai tout rempli comme il faut et aucun objet n'est créé". Mon précédent correctif (champ "Relation" sans cible) était réel mais ne couvrait pas ce cas précis. Cause de fond : chaque nom de colonne (dérivé du nom de champ tapé par l'utilisateur, via slugify) était interpolé TEL QUEL dans du SQL brut (CREATE TABLE, INSERT, UPDATE, ALTER TABLE ADD/DROP/RENAME COLUMN) sans jamais être encadré de guillemets — n'importe quel nom de champ qui soit aussi un mot réservé SQLite (order, group, index, select, where, table, key, default, check, references, unique...) déclenchait exactement le même crash-et-perte-de-transaction, dans n'importe laquelle de ces opérations. Correctif général (pas un simple contournement pour "order") : db/quote_ident.py encadre tout identifiant de colonne de guillemets doubles (forme standard SQL, supportée par SQLite) — appliqué partout où un nom de colonne utilisateur est interpolé dans du SQL brut : create_definition, add_field_to_definition, delete_field, update_field (RENAME COLUMN), insert_row, update_row, update_row_field, rows_referencing. Les noms de TABLE n'ont pas besoin de cette protection (table_name_for.py les préfixe toujours "obj_", donc jamais un mot réservé à eux seuls). Trois nouveaux tests (tests/test_reserved_sql_keyword_field_names.py) : création avec un champ "order" + insertion/lecture/mise à jour d'une ligne, renommage d'un champ vers/depuis un mot réservé ("group"), ajout d'un champ "select" à un objet existant — les trois confirmés en échec sur l'ancien code (même erreur reproduite) puis au vert avec le correctif. 136 tests au vert au total. Co-Authored-By: Claude Sonnet 5 --- db/definitions/add_field_to_definition.py | 5 +- db/definitions/create_definition.py | 5 +- db/definitions/delete_field.py | 3 +- db/definitions/update_field.py | 3 +- db/quote_ident.py | 15 ++++ db/rows/insert_row.py | 4 +- db/rows/rows_referencing.py | 3 +- db/rows/update_row.py | 3 +- db/rows/update_row_field.py | 3 +- .../test_reserved_sql_keyword_field_names.py | 74 +++++++++++++++++++ 10 files changed, 108 insertions(+), 10 deletions(-) create mode 100644 db/quote_ident.py create mode 100644 tests/test_reserved_sql_keyword_field_names.py diff --git a/db/definitions/add_field_to_definition.py b/db/definitions/add_field_to_definition.py index eee858b6..ae2835cd 100644 --- a/db/definitions/add_field_to_definition.py +++ b/db/definitions/add_field_to_definition.py @@ -1,5 +1,6 @@ from ..connection import connect from ..constants import FIELD_TYPES +from ..quote_ident import quote_ident from ..slugify import slugify from .get_definition import get_definition @@ -25,11 +26,11 @@ def add_field_to_definition(slug, definition_id, field): # même simplicité qu'à la création : on ajoute la colonne simple — # c'est la table _fields qui reste la source de vérité utilisée par # le moteur pour savoir que cette colonne est une relation. - conn.execute(f"ALTER TABLE {definition['table_name']} ADD COLUMN {col} INTEGER") + conn.execute(f"ALTER TABLE {definition['table_name']} ADD COLUMN {quote_ident(col)} INTEGER") relation_definition_id = related["id"] else: sql_type = FIELD_TYPES[ftype]["sql"] - conn.execute(f"ALTER TABLE {definition['table_name']} ADD COLUMN {fname} {sql_type}") + conn.execute(f"ALTER TABLE {definition['table_name']} ADD COLUMN {quote_ident(fname)} {sql_type}") min_value = field.get("min_value") if ftype in ("nombre_entier", "nombre_decimal") else None max_value = field.get("max_value") if ftype in ("nombre_entier", "nombre_decimal") else None diff --git a/db/definitions/create_definition.py b/db/definitions/create_definition.py index 56fbbda0..5ef56112 100644 --- a/db/definitions/create_definition.py +++ b/db/definitions/create_definition.py @@ -1,5 +1,6 @@ from ..connection import connect from ..constants import FIELD_TYPES +from ..quote_ident import quote_ident from ..slugify import slugify from ..table_name_for import table_name_for from .get_definition import get_definition @@ -32,13 +33,13 @@ def create_definition(slug, name, fields): related = get_definition(slug, int(f["relation_definition_id"])) col = f"{fname}_id" columns_sql.append( - f"{col} INTEGER REFERENCES {related['table_name']}(id)" + f"{quote_ident(col)} INTEGER REFERENCES {related['table_name']}(id)" ) relation_definition_id = related["id"] else: sql_type = FIELD_TYPES[ftype]["sql"] not_null = " NOT NULL" if required else "" - columns_sql.append(f"{fname} {sql_type}{not_null}") + columns_sql.append(f"{quote_ident(fname)} {sql_type}{not_null}") min_value = f.get("min_value") if ftype in ("nombre_entier", "nombre_decimal") else None max_value = f.get("max_value") if ftype in ("nombre_entier", "nombre_decimal") else None diff --git a/db/definitions/delete_field.py b/db/definitions/delete_field.py index d3bc2adf..a6a4d008 100644 --- a/db/definitions/delete_field.py +++ b/db/definitions/delete_field.py @@ -1,6 +1,7 @@ import sqlite3 from ..connection import connect +from ..quote_ident import quote_ident from ..slugify import slugify from .get_definition import get_definition @@ -20,7 +21,7 @@ def delete_field(slug, definition_id, field_id): col += "_id" conn = connect(slug) try: - conn.execute(f"ALTER TABLE {definition['table_name']} DROP COLUMN {col}") + conn.execute(f"ALTER TABLE {definition['table_name']} DROP COLUMN {quote_ident(col)}") except sqlite3.OperationalError: pass conn.execute("DELETE FROM _fields WHERE id = ?", (field_id,)) diff --git a/db/definitions/update_field.py b/db/definitions/update_field.py index 7a8fc93a..3cc64c8e 100644 --- a/db/definitions/update_field.py +++ b/db/definitions/update_field.py @@ -1,6 +1,7 @@ import sqlite3 from ..connection import connect +from ..quote_ident import quote_ident from ..slugify import slugify from .get_definition import get_definition @@ -31,7 +32,7 @@ def update_field(slug, definition_id, field_id, new_name, required, relation_def if old_col != new_col: try: conn.execute( - f"ALTER TABLE {definition['table_name']} RENAME COLUMN {old_col} TO {new_col}" + f"ALTER TABLE {definition['table_name']} RENAME COLUMN {quote_ident(old_col)} TO {quote_ident(new_col)}" ) except sqlite3.OperationalError: pass # SQLite trop ancien pour RENAME COLUMN : la colonne SQL garde son ancien nom diff --git a/db/quote_ident.py b/db/quote_ident.py new file mode 100644 index 00000000..9cad0551 --- /dev/null +++ b/db/quote_ident.py @@ -0,0 +1,15 @@ +def quote_ident(name): + """Encadre un identifiant SQL (nom de colonne) de guillemets doubles — + forme standard SQL, supportée par SQLite, pour pouvoir utiliser un nom + de colonne qui serait sinon un mot réservé (ex. un champ appelé + "order" : sans ça, "CREATE TABLE ... (order INTEGER)" plantait avec + "OperationalError: near \"order\": syntax error", et comme le crash + survient APRÈS l'INSERT de la ligne _definitions mais AVANT le + commit(), rien n'était jamais persisté — l'objet entier disparaissait + silencieusement, pas seulement le champ en cause). Les noms de colonne + viennent tous de slugify() (utilisateur), jamais les noms de TABLE + (toujours préfixés "obj_" par table_name_for.py, donc jamais un mot + réservé à eux seuls) — seules les colonnes ont besoin de ça. Double les + guillemets internes (échappement standard SQL), au cas improbable où un + nom en contiendrait déjà un.""" + return '"' + str(name).replace('"', '""') + '"' diff --git a/db/rows/insert_row.py b/db/rows/insert_row.py index 5464ce34..e6b40e8a 100644 --- a/db/rows/insert_row.py +++ b/db/rows/insert_row.py @@ -1,4 +1,5 @@ from ..connection import connect +from ..quote_ident import quote_ident from .row_columns_and_values import row_columns_and_values @@ -8,7 +9,8 @@ def insert_row(slug, definition, form_data): cols, values = row_columns_and_values(definition, form_data) conn = connect(slug) placeholders = ["?"] * len(cols) - sql = f"INSERT INTO {definition['table_name']} ({', '.join(cols)}) VALUES ({', '.join(placeholders)})" + quoted_cols = ", ".join(quote_ident(c) for c in cols) + sql = f"INSERT INTO {definition['table_name']} ({quoted_cols}) VALUES ({', '.join(placeholders)})" conn.execute(sql, values) conn.commit() conn.close() diff --git a/db/rows/rows_referencing.py b/db/rows/rows_referencing.py index e7f1f424..fb7adbce 100644 --- a/db/rows/rows_referencing.py +++ b/db/rows/rows_referencing.py @@ -1,4 +1,5 @@ from ..connection import connect +from ..quote_ident import quote_ident from ..slugify import slugify from ..definitions.definitions_referencing import definitions_referencing from ..definitions.get_definition import get_definition @@ -18,7 +19,7 @@ def rows_referencing(slug, definition_id, row_id): col = slugify(f["name"]).replace("-", "_") + "_id" conn = connect(slug) count = conn.execute( - f"SELECT COUNT(*) AS c FROM {full['table_name']} WHERE {col} = ?", (row_id,) + f"SELECT COUNT(*) AS c FROM {full['table_name']} WHERE {quote_ident(col)} = ?", (row_id,) ).fetchone()["c"] conn.close() if count: diff --git a/db/rows/update_row.py b/db/rows/update_row.py index 8ae2cd1e..a1d71bdd 100644 --- a/db/rows/update_row.py +++ b/db/rows/update_row.py @@ -1,4 +1,5 @@ from ..connection import connect +from ..quote_ident import quote_ident from .row_columns_and_values import row_columns_and_values @@ -7,7 +8,7 @@ def update_row(slug, definition, row_id, form_data): valeurs saisies dans le même formulaire généré que pour la création.""" cols, values = row_columns_and_values(definition, form_data) conn = connect(slug) - set_clause = ", ".join(f"{c} = ?" for c in cols) + set_clause = ", ".join(f"{quote_ident(c)} = ?" for c in cols) conn.execute( f"UPDATE {definition['table_name']} SET {set_clause} WHERE id = ?", values + [row_id], diff --git a/db/rows/update_row_field.py b/db/rows/update_row_field.py index 78a1b192..477bef11 100644 --- a/db/rows/update_row_field.py +++ b/db/rows/update_row_field.py @@ -1,4 +1,5 @@ from ..connection import connect +from ..quote_ident import quote_ident from ..slugify import slugify @@ -10,6 +11,6 @@ def update_row_field(slug, definition, row_id, field_def, new_value): fname = slugify(field_def["name"]).replace("-", "_") col = f"{fname}_id" if field_def["type"] == "relation" else fname conn = connect(slug) - conn.execute(f"UPDATE {definition['table_name']} SET {col} = ? WHERE id = ?", (new_value, row_id)) + conn.execute(f"UPDATE {definition['table_name']} SET {quote_ident(col)} = ? WHERE id = ?", (new_value, row_id)) conn.commit() conn.close() diff --git a/tests/test_reserved_sql_keyword_field_names.py b/tests/test_reserved_sql_keyword_field_names.py new file mode 100644 index 00000000..3dc16933 --- /dev/null +++ b/tests/test_reserved_sql_keyword_field_names.py @@ -0,0 +1,74 @@ +"""Régression : un nom de champ qui est un mot réservé SQL (ex. "order") +faisait planter TOUTE requête SQL brute qui le mentionnait telle quelle +(CREATE TABLE, INSERT, UPDATE, ALTER TABLE...) — ex. "OperationalError: +near \"order\": syntax error" dans create_definition(). Comme le crash +survient APRÈS l'INSERT de la ligne _definitions mais AVANT le commit(), +rien n'était jamais persisté : l'objet entier disparaissait, sans le +moindre message d'erreur — vécu comme "le panneau recharge la page sans +créer d'objet" même avec des champs parfaitement remplis. Corrigé en +encadrant chaque nom de colonne de guillemets doubles (db/quote_ident.py) +partout où il est interpolé dans du SQL brut.""" +import db + + +def test_object_created_with_a_reserved_keyword_field_name(client, game): + resp = client.post(f"/game/{game}/objects/new", data={ + "object_name": "dialog", + "field_name[]": ["order", "spiker", "text"], + "field_type[]": ["nombre_entier", "texte", "texte_long"], + "field_relation[]": ["", "", ""], + "field_required[]": ["1", "1", "1"], + "field_min[]": ["1", "", ""], + "field_max[]": ["100", "", ""], + }, follow_redirects=False) + assert resp.status_code == 302 + + definitions = db.list_definitions(game) + assert len(definitions) == 1 + definition = db.get_definition(game, definitions[0]["id"]) + assert [f["name"] for f in definition["fields"]] == ["order", "spiker", "text"] + + # Insertion, lecture ET mise à jour d'une ligne doivent aussi fonctionner + # (insert_row.py / update_row.py / list_rows.py mentionnent aussi "order"). + client.post(f"/game/{game}/objects/{definition['id']}/data/new", + data={"order": "5", "spiker": "Bob", "text": "hello"}) + rows = db.list_rows(game, definition) + assert len(rows) == 1 + assert rows[0]["order"] == 5 + + client.post(f"/game/{game}/objects/{definition['id']}/data/{rows[0]['id']}/edit", + data={"order": "9", "spiker": "Bob", "text": "hello"}) + rows = db.list_rows(game, definition) + assert rows[0]["order"] == 9 + + +def test_field_renamed_to_and_from_a_reserved_keyword(client, game): + resp = client.post(f"/game/{game}/objects/new", data={ + "object_name": "chose", "field_name[]": ["nom"], "field_type[]": ["texte"], + "field_relation[]": [""], "field_required[]": ["0"], "field_min[]": [""], "field_max[]": [""], + }, follow_redirects=False) + def_id = int(resp.headers["Location"].rstrip("/").split("/")[-1]) + field_id = db.get_definition(game, def_id)["fields"][0]["id"] + + resp = client.post(f"/game/{game}/objects/{def_id}/fields/{field_id}/edit", + data={"field_name": "group", "field_required": "0"}, follow_redirects=False) + assert resp.status_code == 302 + assert db.get_definition(game, def_id)["fields"][0]["name"] == "group" + + client.post(f"/game/{game}/objects/{def_id}/data/new", data={"group": "valeur"}) + rows = db.list_rows(game, db.get_definition(game, def_id)) + assert rows[0]["group"] == "valeur" + + +def test_added_field_with_a_reserved_keyword_name(client, game): + resp = client.post(f"/game/{game}/objects/new", data={ + "object_name": "chose", "field_name[]": ["nom"], "field_type[]": ["texte"], + "field_relation[]": [""], "field_required[]": ["0"], "field_min[]": [""], "field_max[]": [""], + }, follow_redirects=False) + def_id = int(resp.headers["Location"].rstrip("/").split("/")[-1]) + + resp = client.post(f"/game/{game}/objects/{def_id}/fields/add", + data={"field_name": "select", "field_type": "texte"}, follow_redirects=False) + assert resp.status_code == 302 + names = [f["name"] for f in db.get_definition(game, def_id)["fields"]] + assert "select" in names -- 2.54.0