Merge pull request 'Dev' (#3) from dev into main
Reviewed-on: #3
This commit was merged in pull request #3.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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('"', '""') + '"'
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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']}%",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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()
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user