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/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/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/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/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/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/scripts/build_demo_dialogues.py b/scripts/build_demo_dialogues.py new file mode 100644 index 00000000..b65c3b88 --- /dev/null +++ b/scripts/build_demo_dialogues.py @@ -0,0 +1,194 @@ +"""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. + +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 +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 !", 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.", color="#f5f6fa") + 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 !", 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é.", color="#f5f6fa") + 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", 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.", color="#f5f6fa") + 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() 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_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(" 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" diff --git a/tests/test_object_new_without_fields.py b/tests/test_object_new_without_fields.py new file mode 100644 index 00000000..6abe07c5 --- /dev/null +++ b/tests/test_object_new_without_fields.py @@ -0,0 +1,71 @@ +"""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) == [] + + +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"] 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