Merge pull request 'Dev' (#3) from dev into main
Build and deploy / build-and-push (push) Successful in 15s
Build and deploy / deploy (push) Successful in 12s

Reviewed-on: #3
This commit was merged in pull request #3.
This commit is contained in:
2026-08-27 17:46:01 +00:00
20 changed files with 511 additions and 28 deletions
+3 -2
View File
@@ -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
+3 -2
View File
@@ -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
+2 -1
View File
@@ -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,))
+2 -1
View File
@@ -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
+15
View File
@@ -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('"', '""') + '"'
+3 -1
View File
@@ -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()
+2 -1
View File
@@ -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:
+2 -1
View File
@@ -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],
+2 -1
View File
@@ -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()
+18 -1
View File
@@ -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']}%",
+9 -3
View File
@@ -8,11 +8,17 @@ from core.flask_app import app
@app.route("/game/<slug>/objects/<int:definition_id>/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,
+8 -1
View File
@@ -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))
+18 -2
View File
@@ -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,
+6 -1
View File
@@ -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 (
+194
View File
@@ -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()
+10 -10
View File
@@ -453,8 +453,8 @@
{% set rows = rows_by_definition[d.id] %}
{% if rows %}
{% for r in rows %}
<form method="post" action="{{ url_for('data_edit', slug=game.slug, definition_id=d.id, row_id=r.id) }}" id="dataEditForm{{ r.id }}"></form>
<form method="post" action="{{ url_for('data_delete', slug=game.slug, definition_id=d.id, row_id=r.id) }}" id="dataDeleteForm{{ r.id }}"
<form method="post" action="{{ url_for('data_edit', slug=game.slug, definition_id=d.id, row_id=r.id) }}" id="dataEditForm{{ d.id }}-{{ r.id }}"></form>
<form method="post" action="{{ url_for('data_delete', slug=game.slug, definition_id=d.id, row_id=r.id) }}" id="dataDeleteForm{{ d.id }}-{{ r.id }}"
onsubmit="return confirm('Supprimer définitivement l\'entrée #{{ r.id }} ?');"></form>
{% endfor %}
<div class="fieldsTableWrap">
@@ -470,18 +470,18 @@
{% set col = f.name|colname %}
<td data-col="field-{{ f.id }}" data-field-label="{{ f.name }}">
{% if f.type == 'texte' %}
<input class="input is-small" type="text" form="dataEditForm{{ r.id }}" name="{{ col }}" value="{{ r[col] or '' }}">
<input class="input is-small" type="text" form="dataEditForm{{ d.id }}-{{ r.id }}" name="{{ col }}" value="{{ r[col] or '' }}">
{% elif f.type == 'texte_long' %}
<textarea class="textarea is-small" form="dataEditForm{{ r.id }}" name="{{ col }}" rows="1">{{ r[col] or '' }}</textarea>
<textarea class="textarea is-small" form="dataEditForm{{ d.id }}-{{ r.id }}" name="{{ col }}" rows="1">{{ r[col] or '' }}</textarea>
{% elif f.type in ('nombre_entier', 'nombre_decimal') %}
<input class="input is-small" type="number" step="{{ '1' if f.type == 'nombre_entier' else 'any' }}" form="dataEditForm{{ r.id }}" name="{{ col }}" value="{{ r[col] if r[col] is not none else '' }}">
<input class="input is-small" type="number" step="{{ '1' if f.type == 'nombre_entier' else 'any' }}" form="dataEditForm{{ d.id }}-{{ r.id }}" name="{{ col }}" value="{{ r[col] if r[col] is not none else '' }}">
{% elif f.type == 'booleen' %}
<label class="checkbox"><input type="checkbox" form="dataEditForm{{ r.id }}" name="{{ col }}" {{ 'checked' if r[col] else '' }}></label>
<label class="checkbox"><input type="checkbox" form="dataEditForm{{ d.id }}-{{ r.id }}" name="{{ col }}" {{ 'checked' if r[col] else '' }}></label>
{% elif f.type == 'date' %}
<input class="input is-small" type="date" form="dataEditForm{{ r.id }}" name="{{ col }}" value="{{ r[col] or '' }}">
<input class="input is-small" type="date" form="dataEditForm{{ d.id }}-{{ r.id }}" name="{{ col }}" value="{{ r[col] or '' }}">
{% elif f.type == 'relation' %}
<div class="select is-small">
<select form="dataEditForm{{ r.id }}" name="{{ col }}_id">
<select form="dataEditForm{{ d.id }}-{{ r.id }}" name="{{ col }}_id">
<option value="">—</option>
{% for opt in relation_options_by_definition[d.id].get(f.id, []) %}
<option value="{{ opt.id }}" {{ 'selected' if r[col ~ '_id'] == opt.id else '' }}>{{ opt.label }}</option>
@@ -493,8 +493,8 @@
{% endfor %}
<td class="fieldsTableActions">
<button type="button" class="button is-small" title="Voir cette entrée en détail" onclick="openRowDetailPanel({{ d.id }}, {{ r.id }})">👁️</button>
<button type="submit" form="dataEditForm{{ r.id }}" class="button is-small">Enregistrer</button>
<button type="submit" form="dataDeleteForm{{ r.id }}" class="button is-small is-danger">🗑️</button>
<button type="submit" form="dataEditForm{{ d.id }}-{{ r.id }}" class="button is-small">Enregistrer</button>
<button type="submit" form="dataDeleteForm{{ d.id }}-{{ r.id }}" class="button is-small is-danger">🗑️</button>
</td>
</tr>
{% endfor %}
+27
View File
@@ -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:<sa place dans le canevas>" 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("<div", 0, idx)
wrapper_tag = html[tag_start:idx + 200]
assert "position:static" in wrapper_tag
assert "position:absolute" not in wrapper_tag
def test_overlay_starts_hidden_by_default(client, game):
"""Régression : une Superposition fraîchement posée ne doit PAS couvrir
tout l'écran dès sa création (position:fixed + inset:0 la ferait sinon
+42
View File
@@ -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 <form id="dataEditForm
{{r.id}}"> 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"
+71
View File
@@ -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"]
@@ -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