Dev #4
@@ -8,6 +8,7 @@ __pycache__/
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
Bug/
|
||||
|
||||
# Variables d'environnement / secrets locaux
|
||||
.env
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import json
|
||||
|
||||
# Partagé par control_value.py (widgets) et filter_repeater_rows.py
|
||||
# (rendering) — module "feuille" sans import de l'un ou l'autre, pour ne
|
||||
# jamais introduire de dépendance circulaire entre ces deux packages.
|
||||
|
||||
|
||||
def decode_clauses(attrs, clauses_attr, combinator_attr, legacy_prefix=None):
|
||||
"""Lit une liste de clauses {"champ", "operateur", "valeur"} depuis un
|
||||
attribut JSON (clauses_attr). Repli sur l'ANCIEN format à 2 filtres
|
||||
fixes (legacy_prefix + "_champ"/"_operateur"/"_valeur", et
|
||||
legacy_prefix + "2_champ"/"2_operateur"/"2_valeur") si clauses_attr est
|
||||
absent — pour ne rien casser sur les éléments créés avant l'ajout des
|
||||
clauses illimitées (aucune migration de données nécessaire, la
|
||||
conversion se fait à la volée, ici, à la lecture). Renvoie toujours
|
||||
(clauses, combinateur), combinateur valant "et" par défaut."""
|
||||
raw = attrs.get(clauses_attr)
|
||||
clauses = None
|
||||
if raw:
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
if isinstance(parsed, list):
|
||||
clauses = parsed
|
||||
except (TypeError, ValueError):
|
||||
clauses = None
|
||||
if clauses is None and legacy_prefix:
|
||||
clauses = []
|
||||
for suffix in ("", "2"):
|
||||
champ = (attrs.get(f"{legacy_prefix}{suffix}_champ") or "").strip()
|
||||
if champ:
|
||||
clauses.append({
|
||||
"champ": champ,
|
||||
"operateur": attrs.get(f"{legacy_prefix}{suffix}_operateur") or "egal",
|
||||
"valeur": attrs.get(f"{legacy_prefix}{suffix}_valeur") or "",
|
||||
})
|
||||
combinateur = attrs.get(combinator_attr) or "et"
|
||||
return clauses or [], combinateur
|
||||
|
||||
|
||||
def encode_clauses(clauses):
|
||||
"""Sérialise une liste de clauses en JSON, en retirant celles sans nom
|
||||
de champ (une ligne ajoutée puis jamais remplie) — "" si la liste
|
||||
nettoyée est vide, pour ne rien écrire de superflu en base."""
|
||||
cleaned = [c for c in clauses if (c.get("champ") or "").strip()]
|
||||
return json.dumps(cleaned) if cleaned else ""
|
||||
@@ -6,6 +6,7 @@ from ..widgets.registry import WIDGETS
|
||||
from ..widgets.widget_meta import widget_meta
|
||||
from ..widgets.default_style_for_widget import default_style_and_attributes
|
||||
from ..element_types.list_element_types import list_element_types
|
||||
from ..element_types.is_overlay_only import _is_overlay_only_element_type
|
||||
|
||||
|
||||
def add_element(slug, screen_id, widget_key, element_type_id=None, parent_id=None):
|
||||
@@ -39,6 +40,15 @@ def add_element(slug, screen_id, widget_key, element_type_id=None, parent_id=Non
|
||||
# proprement) — hors couleurs, qui restent volontairement transparentes/
|
||||
# sans bordure tant qu'on ne les choisit pas explicitement.
|
||||
attributes, style = default_style_and_attributes(widget_key)
|
||||
if element_type_id and _is_overlay_only_element_type(slug, int(element_type_id)):
|
||||
# Un exemplaire dont le modèle N'EST QU'une "Superposition / boîte
|
||||
# de dialogue" (render_element_html.py décide alors, au rendu, de
|
||||
# faire porter le masquage à CET EXEMPLAIRE plutôt qu'au modèle) :
|
||||
# démarre masqué par défaut, exactement comme une superposition
|
||||
# posée directement (voir default_style_for_widget.py) — sinon un
|
||||
# dialogue fraîchement déposé couvrirait tout l'écran en jeu dès
|
||||
# sa pose, avant même qu'une action ne l'affiche.
|
||||
style["display"] = "none"
|
||||
content = meta.get("default_content", "")
|
||||
width, height = meta.get("w", 30), meta.get("h", 10)
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import db
|
||||
|
||||
from ..widgets.widget_meta import widget_meta
|
||||
from ..widgets.layout_capable_widgets import LAYOUT_CAPABLE_WIDGETS
|
||||
from ..clause_list_codec import encode_clauses
|
||||
from .get_element import get_element
|
||||
|
||||
|
||||
@@ -53,6 +54,32 @@ def save_element_controls(slug, element_id, form):
|
||||
toggle_groups[name].add(control["token"])
|
||||
continue
|
||||
|
||||
if ctype == "clause_list":
|
||||
# Nombre ILLIMITÉ de lignes (voir c_clause_list.py) : un champ
|
||||
# tableau par colonne ("field_name_champ[]"/"_operateur[]"/
|
||||
# "_valeur[]"), reconstituées ici en une liste de clauses par
|
||||
# position (zip) — même principe que field_name[]/field_type[]
|
||||
# pour les champs d'un objet (routes/objects/parse_field_rows.py).
|
||||
champs = form.getlist(field_name + "_champ[]")
|
||||
operateurs = form.getlist(field_name + "_operateur[]")
|
||||
valeurs = form.getlist(field_name + "_valeur[]")
|
||||
clauses = [
|
||||
{"champ": champ, "operateur": operateurs[i] if i < len(operateurs) else "egal",
|
||||
"valeur": valeurs[i] if i < len(valeurs) else ""}
|
||||
for i, champ in enumerate(champs)
|
||||
]
|
||||
encoded = encode_clauses(clauses)
|
||||
if encoded:
|
||||
attributes[control["clauses_attr"]] = encoded
|
||||
else:
|
||||
attributes.pop(control["clauses_attr"], None)
|
||||
combinateur = form.get(field_name + "_combinateur") or "et"
|
||||
if combinateur == "ou":
|
||||
attributes[control["combinator_attr"]] = combinateur
|
||||
else:
|
||||
attributes.pop(control["combinator_attr"], None)
|
||||
continue
|
||||
|
||||
if ctype == "preset":
|
||||
chosen = form.get(field_name, control.get("default"))
|
||||
opt = next((o for o in control["options"] if o[0] == chosen), None)
|
||||
|
||||
@@ -62,6 +62,10 @@ FLOW_NODE_FIELDS = {
|
||||
# qui reste la première clause) + cond_combinator ('et'/'ou') pour savoir
|
||||
# comment les combiner. Absents => comportement legacy (une seule clause).
|
||||
"cond_clauses", "cond_combinator",
|
||||
# Condition sur une VARIABLE GLOBALE plutôt qu'un champ d'objet — voir
|
||||
# ensure_flow_schema.py pour le détail des 3 clés (cond_source vaut
|
||||
# "objet" ou "variable" ; absent => "objet", comportement historique).
|
||||
"cond_source", "cond_variable", "cond_variable_chemin",
|
||||
"action_type", "target_screen_id", "target_element_id", "element_property", "element_value",
|
||||
"target_definition_id", "target_row_id", "target_field", "data_operation", "data_value",
|
||||
"target_variable",
|
||||
|
||||
@@ -56,5 +56,21 @@ def ensure_flow_schema(slug):
|
||||
# cible une variable globale par NOM plutôt qu'un champ d'objet ;
|
||||
# réutilise data_operation/data_value, déjà là pour "modifier_donnee".
|
||||
conn.execute("ALTER TABLE _flow_nodes ADD COLUMN target_variable TEXT")
|
||||
if "cond_source" not in cols:
|
||||
# Un nœud Condition peut désormais tester soit un champ d'objet
|
||||
# (comportement historique, cond_source absent ou "objet"), soit
|
||||
# une VARIABLE GLOBALE (cond_source="variable" — cond_variable :
|
||||
# son nom ; cond_variable_chemin : chemin optionnel ".champ"/
|
||||
# "[index]" si elle est de type objet/tableau, même syntaxe que
|
||||
# "Chemin dans la variable" de la Condition de visibilité — voir
|
||||
# visibility_condition_controls.py). Chaque clause de cond_clauses
|
||||
# (JSON) porte les mêmes 3 clés en plus des siennes. Évalué côté
|
||||
# CLIENT (templates/play.html, evaluateConditionClause) contre
|
||||
# gameData.variables, exposé par full_game_payload.py.
|
||||
conn.execute("ALTER TABLE _flow_nodes ADD COLUMN cond_source TEXT")
|
||||
if "cond_variable" not in cols:
|
||||
conn.execute("ALTER TABLE _flow_nodes ADD COLUMN cond_variable TEXT")
|
||||
if "cond_variable_chemin" not in cols:
|
||||
conn.execute("ALTER TABLE _flow_nodes ADD COLUMN cond_variable_chemin TEXT")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
@@ -67,7 +67,16 @@ def full_game_payload(slug):
|
||||
# modèle est utilisé ailleurs.
|
||||
element_types = {str(t["id"]): t["template_screen_id"] for t in list_element_types(slug)}
|
||||
|
||||
# Instantané des variables globales, pour qu'un nœud Condition puisse en
|
||||
# tester une côté CLIENT (evaluateConditionClause, templates/play.html) —
|
||||
# {{$var}} (Répéteur/Condition de visibilité), lui, reste résolu côté
|
||||
# SERVEUR au rendu (filter_repeater_rows.py) et n'a jamais eu besoin de
|
||||
# ça. refreshRuntimeData() récupère un payload entier (donc des
|
||||
# variables à jour) après toute action qui en modifie une.
|
||||
variables = {v["name"]: {"value": v["value"], "type": v["type"]} for v in db.list_global_variables(slug)}
|
||||
|
||||
return {
|
||||
"screens": payload_screens, "flows": flows, "animations": animations,
|
||||
"element_types": element_types, "data": data, "fields_meta": fields_meta,
|
||||
"variables": variables,
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import re
|
||||
|
||||
import db
|
||||
|
||||
from ..clause_list_codec import decode_clauses
|
||||
|
||||
# {{NomDeLObjet.nom_du_champ}} : référence à un AUTRE objet de données, pour
|
||||
# comparer le champ filtré à une valeur qui change en cours de partie (ex:
|
||||
# le niveau atteint) plutôt qu'à une valeur fixe tapée en dur — même
|
||||
@@ -154,13 +156,30 @@ def _compare(actual, operator, expected, field_type):
|
||||
return False
|
||||
|
||||
|
||||
def _apply_one_filter(slug, definition, rows, field_name, operator, raw_value):
|
||||
def _clause_matches(slug, definition, row, clause):
|
||||
field_name = (clause.get("champ") or "").strip()
|
||||
if not field_name:
|
||||
return rows
|
||||
expected = _resolve_filter_value(slug, raw_value or "")
|
||||
return True
|
||||
operator = clause.get("operateur") or "egal"
|
||||
expected = _resolve_filter_value(slug, clause.get("valeur") or "")
|
||||
field_type = next((f["type"] for f in definition["fields"] if f["name"] == field_name), "texte")
|
||||
col = _field_column(definition, field_name)
|
||||
return [r for r in rows if _compare(r.get(col), operator, expected, field_type)]
|
||||
return _compare(row.get(col), operator, expected, field_type)
|
||||
|
||||
|
||||
def filter_rows_by_clauses(slug, definition, rows, clauses, combinator):
|
||||
"""Ne garde, parmi les lignes, que celles qui satisfont la liste de
|
||||
clauses données (un nombre quelconque), combinées entre elles par ET
|
||||
(toutes vraies) ou par OU (au moins une vraie) selon `combinator`
|
||||
("et"/"ou"). Utilisé aussi bien par le Répéteur de données (clauses
|
||||
reconstruites depuis _filtre_champ/_filtre2_champ, voir
|
||||
filter_repeater_rows ci-dessous) que par la "Donnée liée" d'un Texte/
|
||||
Titre (clauses illimitées, voir resolve_bound_row.py)."""
|
||||
if not clauses:
|
||||
return rows
|
||||
if combinator == "ou":
|
||||
return [r for r in rows if any(_clause_matches(slug, definition, r, c) for c in clauses)]
|
||||
return [r for r in rows if all(_clause_matches(slug, definition, r, c) for c in clauses)]
|
||||
|
||||
|
||||
def filter_repeater_rows(slug, definition, rows, attrs):
|
||||
@@ -172,12 +191,5 @@ def filter_repeater_rows(slug, definition, rows, attrs):
|
||||
sur un écran unique") : c'est ce qui permet à un seul écran de n'afficher,
|
||||
par exemple, que le niveau du parcours en cours (2 conditions : le bon
|
||||
parcours ET le bon numéro de niveau) plutôt que tous les niveaux."""
|
||||
rows = _apply_one_filter(
|
||||
slug, definition, rows,
|
||||
(attrs.get("_filtre_champ") or "").strip(), attrs.get("_filtre_operateur") or "egal", attrs.get("_filtre_valeur"),
|
||||
)
|
||||
rows = _apply_one_filter(
|
||||
slug, definition, rows,
|
||||
(attrs.get("_filtre2_champ") or "").strip(), attrs.get("_filtre2_operateur") or "egal", attrs.get("_filtre2_valeur"),
|
||||
)
|
||||
return rows
|
||||
clauses, combinator = decode_clauses(attrs, "_filtres_json", "_filtre_combinateur", legacy_prefix="_filtre")
|
||||
return filter_rows_by_clauses(slug, definition, rows, clauses, combinator)
|
||||
|
||||
@@ -117,19 +117,35 @@ def render_element_html(el, slug=None, children_map=None, ctx=None, parent_flex_
|
||||
# Un exemplaire d'élément de jeu est posé par défaut avec le widget
|
||||
# générique "conteneur" (add_element.py, "default_widget") — utile
|
||||
# pour la plupart des modèles, mais QUAND le modèle entier n'est
|
||||
# qu'une "Superposition / boîte de dialogue", cette enveloppe
|
||||
# (fond, bordure, position normale sur le canevas — voir
|
||||
# widgets/registry.py "conteneur") resterait visible EN PERMANENCE
|
||||
# à l'endroit où l'exemplaire a été déposé, alors que la
|
||||
# superposition à l'intérieur gère déjà entièrement sa propre
|
||||
# apparence et son propre masquage (position:fixed plein écran,
|
||||
# démarre masquée) — vécu comme "un conteneur vide apparaît sur la
|
||||
# scène, pas la boîte de dialogue" (en réalité la boîte de dialogue
|
||||
# existe bien, juste masquée comme prévu ; c'est le conteneur
|
||||
# AUTOUR qui n'aurait jamais dû être visible). On court-circuite
|
||||
# donc entièrement l'enveloppe et on renvoie directement le
|
||||
# contenu du modèle.
|
||||
return _mark(child_html)
|
||||
# qu'une "Superposition / boîte de dialogue", la classe Bulma "box"
|
||||
# de ce widget (widgets/registry.py "conteneur") lui donnait un
|
||||
# fond opaque TOUJOURS VISIBLE à l'endroit où l'exemplaire a été
|
||||
# déposé, alors que la superposition à l'intérieur gère déjà
|
||||
# entièrement sa propre apparence et son propre masquage
|
||||
# (position:fixed plein écran, démarre masquée) — vécu comme "un
|
||||
# conteneur vide apparaît sur la scène, pas la boîte de dialogue"
|
||||
# (en réalité la boîte de dialogue existe bien, juste masquée comme
|
||||
# prévu ; c'est le conteneur AUTOUR qui n'aurait jamais dû avoir sa
|
||||
# propre apparence visuelle).
|
||||
#
|
||||
# On ne court-circuite PLUS entièrement cette enveloppe (un essai
|
||||
# précédent le faisait, en renvoyant `child_html` directement) :
|
||||
# cette balise porte le SEUL id/data-element-id de CET EXEMPLAIRE
|
||||
# précis (id=el["id"], distinct de l'id interne du modèle,
|
||||
# ex. l'overlay du modèle "dialog" garde son propre id 72 alors que
|
||||
# CET exemplaire est, par ex., 77) — une action "Modifier un
|
||||
# élément" posée sur LA SCÈNE qui l'accueille (pas sur le modèle
|
||||
# lui-même) cible cet id d'exemplaire, jamais celui, interne et
|
||||
# partagé par tous les exemplaires, du modèle. La court-circuiter
|
||||
# entièrement faisait disparaître cet id du DOM, rendant
|
||||
# IMPOSSIBLE tout contrôle de la boîte de dialogue depuis la
|
||||
# logique de la scène qui l'accueille ("j'ai aucun contrôle sur la
|
||||
# visibilité de la modale depuis la scène"). On garde donc la
|
||||
# balise (avec son id), mais SANS la classe "box" du widget
|
||||
# générique — juste une balise transparente, invisible tant
|
||||
# qu'elle ne porte ni fond ni bordure."""
|
||||
attrs.pop("class", None)
|
||||
return _mark(f"<div {_attr_string(attrs, style)}>{child_html}</div>")
|
||||
|
||||
if tag in ("ul", "ol"):
|
||||
items = [line.strip() for line in content.split("\n") if line.strip()]
|
||||
@@ -154,12 +170,29 @@ def _render_element_type_children(el, slug, ctx):
|
||||
de ce mécanisme) plutôt que d'afficher un exemplaire silencieusement
|
||||
vide. Le second élément renvoyé indique si le modèle N'EST QUE une
|
||||
superposition (voir l'appelant : dans ce cas précis, l'enveloppe
|
||||
générique "conteneur" de l'exemplaire doit être court-circuitée)."""
|
||||
générique "conteneur" de l'exemplaire ne doit pas avoir sa propre
|
||||
apparence visuelle)."""
|
||||
template_tree = _load_template_tree(slug, el["element_type_id"])
|
||||
if not template_tree:
|
||||
return None, False
|
||||
is_overlay_only = _is_overlay_only_element_type(slug, el["element_type_id"])
|
||||
if is_overlay_only:
|
||||
# Le masquage de CET EXEMPLAIRE est entièrement délégué à
|
||||
# l'enveloppe (voir l'appelant : add_element.py la fait démarrer
|
||||
# masquée par défaut, exactement comme la superposition elle-même)
|
||||
# — la superposition INTERNE au modèle ne doit donc plus appliquer
|
||||
# SON PROPRE réglage "Visibilité" une fois posée comme exemplaire
|
||||
# ailleurs (sinon double masquage : rendre l'enveloppe visible
|
||||
# depuis la scène qui l'accueille resterait sans aucun effet
|
||||
# visible tant que la superposition interne, elle, reste "Masqué"
|
||||
# dans le modèle). On retire donc "display" de sa copie de style
|
||||
# avant rendu (copie éphémère, jamais réenregistrée) — seul le
|
||||
# display:flex de base (render_overlay.py) s'applique alors.
|
||||
overlay_root = dict(template_tree["top"][0])
|
||||
overlay_root["style"] = {k: v for k, v in overlay_root["style"].items() if k != "display"}
|
||||
template_tree["top"][0] = overlay_root
|
||||
html = "".join(
|
||||
render_element_html(c, slug, template_tree["children_map"], ctx)
|
||||
for c in template_tree["top"]
|
||||
)
|
||||
return html, _is_overlay_only_element_type(slug, el["element_type_id"])
|
||||
return html, is_overlay_only
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import db
|
||||
|
||||
from .filter_repeater_rows import filter_repeater_rows, _field_column
|
||||
from .filter_repeater_rows import filter_rows_by_clauses, _field_column
|
||||
from ..clause_list_codec import decode_clauses
|
||||
|
||||
|
||||
def _resolve_bound_row_ctx(slug, attrs):
|
||||
"""Si cet élément est lié à un objet de données (attr:_data_definition_id —
|
||||
réglage "Lier à un objet de données" des widgets Texte/Titre, voir
|
||||
DATA_BINDING_CONTROLS), retrouve LA ligne qui correspond aux 1 ou 2
|
||||
filtres réglés (_data_filtre*/_data_filtre2*) — même moteur que le
|
||||
DATA_BINDING_CONTROLS), retrouve LA ligne qui correspond aux conditions
|
||||
réglées (un nombre quelconque, combinées par ET/OU — attr:
|
||||
_data_filtres_json/_data_filtre_combinateur, avec repli sur l'ancien
|
||||
format à 2 filtres fixes _data_filtre*/_data_filtre2* pour les éléments
|
||||
créés avant l'ajout des clauses illimitées) — même moteur que le
|
||||
filtre du Répéteur de données, voir filter_repeater_rows.py — et
|
||||
renvoie ses champs sous forme de {nom_du_champ: valeur en texte},
|
||||
exploitable par {{champ}} dans le contenu. Permet d'afficher UNE seule
|
||||
@@ -24,14 +28,8 @@ def _resolve_bound_row_ctx(slug, attrs):
|
||||
if not definition:
|
||||
return None
|
||||
rows = db.list_rows(slug, definition)
|
||||
rows = filter_repeater_rows(slug, definition, rows, {
|
||||
"_filtre_champ": attrs.get("_data_filtre_champ"),
|
||||
"_filtre_operateur": attrs.get("_data_filtre_operateur"),
|
||||
"_filtre_valeur": attrs.get("_data_filtre_valeur"),
|
||||
"_filtre2_champ": attrs.get("_data_filtre2_champ"),
|
||||
"_filtre2_operateur": attrs.get("_data_filtre2_operateur"),
|
||||
"_filtre2_valeur": attrs.get("_data_filtre2_valeur"),
|
||||
})
|
||||
clauses, combinator = decode_clauses(attrs, "_data_filtres_json", "_data_filtre_combinateur", legacy_prefix="_data_filtre")
|
||||
rows = filter_rows_by_clauses(slug, definition, rows, clauses, combinator)
|
||||
if not rows:
|
||||
return None
|
||||
row = rows[0]
|
||||
|
||||
@@ -1,26 +1,20 @@
|
||||
from ..controls.c_text import c_text
|
||||
from ..controls.c_select import c_select
|
||||
from ..controls.c_clause_list import c_clause_list
|
||||
from ...flow.constants import CONDITION_OPERATORS
|
||||
|
||||
# Permet à un Texte/Titre d'afficher directement la valeur d'un champ d'un
|
||||
# AUTRE objet de données, sans avoir besoin de le poser dans un Répéteur —
|
||||
# utile pour une valeur unique calculée (ex: l'objectif du niveau en cours)
|
||||
# plutôt qu'une liste. La ligne affichée est celle qui correspond aux 1 ou 2
|
||||
# filtres réglés ci-dessous (mêmes réglages, même moteur que ceux du
|
||||
# Répéteur de données — voir filter_repeater_rows.py) ; sans filtre, c'est la
|
||||
# ligne la plus récente de l'objet choisi (convention déjà utilisée par la
|
||||
# Jauge pour un objet "état de partie" qui ne garde qu'une seule ligne).
|
||||
# plutôt qu'une liste. La ligne affichée est celle qui correspond aux
|
||||
# conditions réglées ci-dessous (un nombre quelconque, combinées par ET/OU —
|
||||
# mêmes clauses, même moteur que le Répéteur de données, voir
|
||||
# filter_repeater_rows.py) ; sans condition, c'est la ligne la plus récente
|
||||
# de l'objet choisi (convention déjà utilisée par la Jauge pour un objet
|
||||
# "état de partie" qui ne garde qu'une seule ligne).
|
||||
DATA_BINDING_CONTROLS = [
|
||||
c_select("data_definition_id", "Lier à un objet de données (pour {{champ}} ci-dessus)",
|
||||
"attr:_data_definition_id", [("", "(sélectionner un objet)")], default="", group="Donnée liée"),
|
||||
c_select("data_filtre_champ", "Trouver la ligne où ce champ",
|
||||
"attr:_data_filtre_champ", [("", "(sélectionner un objet d'abord)")], default="", group="Donnée liée"),
|
||||
c_select("data_filtre_operateur", "...est", "attr:_data_filtre_operateur", CONDITION_OPERATORS, default="egal", group="Donnée liée"),
|
||||
c_text("data_filtre_valeur", "...cette valeur",
|
||||
"attr:_data_filtre_valeur", placeholder="ex. 3", default="", group="Donnée liée"),
|
||||
c_select("data_filtre2_champ", "ET (optionnel) : ce 2ᵉ champ",
|
||||
"attr:_data_filtre2_champ", [("", "(sélectionner un objet d'abord)")], default="", group="Donnée liée"),
|
||||
c_select("data_filtre2_operateur", "...est", "attr:_data_filtre2_operateur", CONDITION_OPERATORS, default="egal", group="Donnée liée"),
|
||||
c_text("data_filtre2_valeur", "...cette valeur",
|
||||
"attr:_data_filtre2_valeur", placeholder="ex. 1", default="", group="Donnée liée"),
|
||||
c_clause_list("data_filtres", "Trouver la ligne où...",
|
||||
clauses_attr="_data_filtres_json", combinator_attr="_data_filtre_combinateur",
|
||||
operators=CONDITION_OPERATORS, legacy_prefix="_data_filtre", group="Donnée liée"),
|
||||
]
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import re
|
||||
|
||||
from ..clause_list_codec import decode_clauses
|
||||
|
||||
|
||||
def _control_value(el, control):
|
||||
"""Lit la valeur actuelle d'un réglage à partir de l'élément stocké
|
||||
@@ -7,8 +9,34 @@ def _control_value(el, control):
|
||||
ctype = control["type"]
|
||||
style = el.get("style") or {}
|
||||
|
||||
if ctype == "clause_list":
|
||||
attrs = el.get("attributes") or {}
|
||||
clauses, combinateur = decode_clauses(
|
||||
attrs, control["clauses_attr"], control["combinator_attr"],
|
||||
legacy_prefix=control.get("legacy_prefix"),
|
||||
)
|
||||
return {"clauses": clauses, "combinateur": combinateur}
|
||||
|
||||
if ctype == "preset":
|
||||
for key, _label, props in control["options"]:
|
||||
# Trié par nombre de propriétés NON VIDES exigées, décroissant : une
|
||||
# option comme "Visible normalement" (visibility_control.py), dont
|
||||
# les critères ({"visibility": ""}) ne portent QUE sur "visibility"
|
||||
# (par choix assumé, pour ne jamais toucher "display" — voir son
|
||||
# commentaire), matchait sinon TOUJOURS en premier dès que
|
||||
# "visibility" est absent, MÊME quand "display:none" (posé par
|
||||
# "Masqué", un critère plus spécifique à 1 propriété non vide contre
|
||||
# 0 pour "Visible") est également présent -- le panneau affichait
|
||||
# alors "Visible normalement" sélectionné sur un élément en réalité
|
||||
# masqué, et comme un <select> ne redéclenche un enregistrement que
|
||||
# sur un VRAI changement de valeur, il devenait impossible de le
|
||||
# rendre visible depuis le panneau (choisir l'option déjà affichée
|
||||
# ne fait rien). Les options plus spécifiques (plus de critères non
|
||||
# vides) doivent donc toujours être vérifiées EN PREMIER.
|
||||
options = sorted(
|
||||
control["options"],
|
||||
key=lambda o: -sum(1 for v in o[2].values() if v),
|
||||
)
|
||||
for key, _label, props in options:
|
||||
if all((style.get(k) or "") == (v or "") for k, v in props.items()):
|
||||
return key
|
||||
return control.get("default")
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
def c_clause_list(key, label, clauses_attr, combinator_attr, operators, legacy_prefix=None, group=None):
|
||||
"""Réglage "liste de conditions" — un nombre ILLIMITÉ de clauses
|
||||
(champ/opérateur/valeur), combinées entre elles par ET ou par OU (voir
|
||||
aussi les nœuds Condition du graphe de logique, qui ont le même besoin
|
||||
— screens/flow/constants.py, cond_clauses/cond_combinator — même
|
||||
principe, appliqué ici à la Donnée liée d'un Texte/Titre).
|
||||
|
||||
Volontairement PAS de "target" (contrairement aux autres réglages) :
|
||||
stocké dans DEUX attributs séparés (clauses_attr : JSON de la liste ;
|
||||
combinator_attr : "et"/"ou"), traités par un code dédié à chaque étape
|
||||
(control_value.py, save_element_controls.py, controls_with_values.py,
|
||||
templates/screen_edit.html) plutôt que par le chemin générique "un
|
||||
réglage = une seule valeur = une seule clé de style/attribut"."""
|
||||
return {
|
||||
"type": "clause_list", "key": key, "label": label,
|
||||
"clauses_attr": clauses_attr, "combinator_attr": combinator_attr,
|
||||
"operators": operators, "legacy_prefix": legacy_prefix, "default": None, "group": group,
|
||||
}
|
||||
@@ -78,8 +78,12 @@ def controls_with_values(slug, el, template_definition_id=None):
|
||||
control["options"] = [("", empty_label)] + [(f["name"], _field_label(f)) for f in non_relation_fields]
|
||||
if control["key"] in ("filtre_champ", "filtre2_champ"):
|
||||
control["options"] = [("", "(sélectionner un objet d'abord)")] + [(f["name"], _field_label(f)) for f in jauge_options["fields"]]
|
||||
if control["key"] in ("data_filtre_champ", "data_filtre2_champ"):
|
||||
control["options"] = [("", "(sélectionner un objet d'abord)")] + [(f["name"], _field_label(f)) for f in data_binding_options["fields"]]
|
||||
if control["type"] == "clause_list":
|
||||
# Options du sélecteur "champ" de CHAQUE ligne de condition —
|
||||
# les mêmes champs que data_filtre_champ ci-dessus (même objet
|
||||
# lié, attr:_data_definition_id), exposées une seule fois ici
|
||||
# plutôt que dupliquées par ligne (nombre de lignes variable).
|
||||
control["field_options"] = [(f["name"], _field_label(f)) for f in data_binding_options["fields"]]
|
||||
if control["key"] == "content":
|
||||
# Une fois "Lier à un objet de données" réglé (voir
|
||||
# DATA_BINDING_CONTROLS), propose les champs de CET objet en
|
||||
|
||||
@@ -70,6 +70,17 @@ button.danger{ color:var(--danger); border-color:rgba(226,104,95,.4); }
|
||||
.fieldRowCard{ margin-bottom:10px; }
|
||||
.relationTarget{ margin-top:6px; }
|
||||
|
||||
/* Liste de conditions de "Donnée liée" (voir c_clause_list.py) : un nombre
|
||||
ILLIMITÉ de lignes peut s'y accumuler — sans limite de hauteur, le
|
||||
panneau de propriétés entier deviendrait interminable à faire défiler
|
||||
dès qu'on en pose plus de 3-4. max-height + overflow-y borne cette
|
||||
LISTE elle-même à une hauteur confortable, le reste du panneau (et le
|
||||
bouton "+ Ajouter une condition" juste en dessous) reste toujours à
|
||||
portée sans avoir à chercher le bas de la liste. */
|
||||
.clauseListWrap{ max-height:260px; overflow-y:auto; padding-right:2px; }
|
||||
.clauseRow{ margin-bottom:8px; }
|
||||
.clauseRow:last-child{ margin-bottom:0; }
|
||||
|
||||
/* .dataTable : Bulma fournit déjà la mise en page du tableau (classes
|
||||
"table is-fullwidth is-hoverable", voir data_list.html) — ceci ne fait
|
||||
plus que tronquer les cellules trop longues avec "...", pour garder une
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
<!-- Une condition du réglage "Donnée liée" (voir screen_edit.html,
|
||||
c.type == 'clause_list') — même présentation qu'un réglage normal du
|
||||
panneau : un "Champ" et un "...est" clairement étiquetés, et le même
|
||||
sélecteur de valeur fixe/dynamique/variable que partout ailleurs dans
|
||||
l'outil (jamais la syntaxe "{{ }}" brute à lire/taper). Inclus une
|
||||
fois par condition existante, et une fois de plus (avec une clause
|
||||
vide) dans le <template> cloné par "+ Ajouter une condition". -->
|
||||
<div class="fieldRowCard box clauseRow">
|
||||
<div class="field">
|
||||
<label class="label is-small">Champ</label>
|
||||
<div class="control">
|
||||
<div class="select is-small is-fullwidth">
|
||||
<select class="clauseChampSelect" name="ctrl_{{ c.key }}_champ[]">
|
||||
<option value="">(sélectionner un objet d'abord)</option>
|
||||
{% for fname, flabel in c.field_options %}
|
||||
<option value="{{ fname }}" {{ 'selected' if clause.champ == fname else '' }}>{{ flabel }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label is-small">...est</label>
|
||||
<div class="control">
|
||||
<div class="select is-small is-fullwidth">
|
||||
<select class="clauseOperateurSelect" name="ctrl_{{ c.key }}_operateur[]">
|
||||
{% for opv, opl in c.operators %}
|
||||
<option value="{{ opv }}" {{ 'selected' if clause.operateur == opv else '' }}>{{ opl }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label is-small">...cette valeur</label>
|
||||
<div class="control filterValueControl" data-current-value="{{ clause.valeur or '' }}">
|
||||
<div class="select is-small is-fullwidth">
|
||||
<select class="filterValueMode" onchange="onFilterValueModeChange(this)">
|
||||
<option value="fixe">Valeur fixe</option>
|
||||
<option value="dynamique">Donnée d'un autre objet (change en jeu)</option>
|
||||
<option value="variable">Variable globale (change en jeu)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filterValueFixe" style="margin-top:6px;">
|
||||
<input class="input is-small" type="text" name="ctrl_{{ c.key }}_valeur[]" value="{{ clause.valeur or '' }}" placeholder="ex. 3">
|
||||
</div>
|
||||
<div class="filterValueDynamique" style="display:none; margin-top:6px;">
|
||||
<div class="select is-small is-fullwidth">
|
||||
<select class="filterValueObjSel" onchange="onFilterValueObjChange(this)">
|
||||
<option value="">— objet —</option>
|
||||
{% for d in definitions %}
|
||||
<option value="{{ d.name }}" data-definition-id="{{ d.id }}">{{ d.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="select is-small is-fullwidth" style="margin-top:6px;">
|
||||
<select class="filterValueFieldSel" onchange="onFilterValueFieldChange(this)">
|
||||
<option value="">— champ —</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="filterValueVariable" style="display:none; margin-top:6px;">
|
||||
<div class="select is-small is-fullwidth">
|
||||
<select class="filterValueVarSel" onchange="onFilterValueVarChange(this)">
|
||||
<option value="">— variable —</option>
|
||||
{% for v in global_variables %}
|
||||
<option value="{{ v.name }}">{{ v.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<input class="input is-small filterValueVarPath" type="text" placeholder="Chemin optionnel (ex. .nom ou [0].valeur)" style="margin-top:6px;" oninput="updateFilterValueFromVariable(this.closest('.filterValueControl'))">
|
||||
{% if not global_variables %}
|
||||
<p class="help">Aucune variable globale pour l'instant — <a href="{{ url_for('game_dashboard', slug=game.slug, tab='variables') }}" target="_blank">crées-en une ↗</a></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="button is-small is-danger clauseRemoveBtn">🗑️ Retirer cette condition</button>
|
||||
</div>
|
||||
+49
-1
@@ -835,13 +835,61 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Lit la valeur ACTUELLE d'une variable globale (gameData.variables,
|
||||
// exposé par full_game_payload.py, tenu à jour par refreshRuntimeData()
|
||||
// après toute action qui en modifie une), pour l'évaluation d'une
|
||||
// condition — équivalent, côté client, de _resolve_filter_value côté
|
||||
// serveur (screens/rendering/filter_repeater_rows.py), mais SANS la
|
||||
// syntaxe utilisée par le Répéteur/la Condition de visibilité (jamais
|
||||
// nécessaire ici : le nom de la variable est choisi dans un menu
|
||||
// déroulant, voir screen_edit.html).
|
||||
function readVariableValue(name) {
|
||||
const v = (gameData.variables || {})[name];
|
||||
return v ? v.value : undefined;
|
||||
}
|
||||
|
||||
// Navigue dans une valeur JSON (variable de type "objet"/"tableau")
|
||||
// selon un chemin ".champ"/"[index]" chaînable — équivalent JS de
|
||||
// _resolve_variable_path (même fichier Python que ci-dessus). Chemin
|
||||
// vide -> valeur brute inchangée (le cas normal pour une variable
|
||||
// scalaire). Ne lève jamais : JSON invalide ou chemin qui ne correspond
|
||||
// à rien -> null, comme côté serveur.
|
||||
function resolveVariablePath(rawValue, path) {
|
||||
if (!path) return rawValue;
|
||||
let current;
|
||||
try { current = rawValue ? JSON.parse(rawValue) : null; } catch (e) { return null; }
|
||||
const segmentRe = /\.([^.\[\]]+)|\[(\d+)\]/g;
|
||||
let m;
|
||||
while ((m = segmentRe.exec(path)) !== null) {
|
||||
if (current === null || current === undefined) return null;
|
||||
current = m[1] !== undefined ? current[m[1]] : current[parseInt(m[2], 10)];
|
||||
}
|
||||
return current === undefined ? null : current;
|
||||
}
|
||||
|
||||
// 2.4 — conditions combinées (ET/OU) : un nœud Condition peut porter une
|
||||
// liste cond_clauses (JSON) en plus de sa clause historique. Un nœud sans
|
||||
// cond_clauses (tous les nœuds créés avant 2.4, ou un nœud à une seule
|
||||
// clause) garde EXACTEMENT son ancien comportement — une seule comparaison.
|
||||
//
|
||||
// Chaque clause peut tester soit un champ d'objet (source absente/"objet",
|
||||
// comportement historique), soit une VARIABLE GLOBALE (source
|
||||
// "variable" — voir ensure_flow_schema.py pour cond_source/cond_variable/
|
||||
// cond_variable_chemin).
|
||||
function evaluateConditionClause(clause) {
|
||||
const source = clause.source ?? clause.cond_source ?? 'objet';
|
||||
const operator = clause.operator ?? clause.cond_operator;
|
||||
const expected = clause.value ?? clause.cond_value;
|
||||
if (source === 'variable') {
|
||||
const varName = clause.variable ?? clause.cond_variable;
|
||||
const path = clause.variable_chemin ?? clause.cond_variable_chemin;
|
||||
const varInfo = (gameData.variables || {})[varName];
|
||||
const actual = resolveVariablePath(readVariableValue(varName), path);
|
||||
const fieldType = varInfo && varInfo.type === 'booleen' ? 'booleen' : '';
|
||||
return compareValues(actual, operator, expected, fieldType);
|
||||
}
|
||||
const actual = readFieldValue(clause.definition_id ?? clause.cond_definition_id, clause.row_id ?? clause.cond_row_id, clause.field ?? clause.cond_field);
|
||||
return compareValues(actual, clause.operator ?? clause.cond_operator, clause.value ?? clause.cond_value, clause.field_type ?? clause.cond_field_type);
|
||||
return compareValues(actual, operator, expected, clause.field_type ?? clause.cond_field_type);
|
||||
}
|
||||
|
||||
function evaluateConditionNode(node) {
|
||||
|
||||
+241
-34
@@ -219,16 +219,40 @@
|
||||
</div>
|
||||
|
||||
<div id="nodeFormCondition" style="display:none;">
|
||||
{% if not definitions %}
|
||||
<p class="hint">Aucun objet défini dans ce jeu pour l'instant — <a href="{{ url_for('game_dashboard', slug=game.slug, tab='objects') }}">crées-en un</a>.</p>
|
||||
{% else %}
|
||||
<label>Objet<select id="nf-cond-definition" onchange="onFlowDefinitionChange('cond')">
|
||||
{% for d in definitions %}
|
||||
<option value="{{ d.id }}">{{ d.name }}</option>
|
||||
{% endfor %}
|
||||
<label>Source<select id="nf-cond-source" onchange="onFlowConditionSourceChange()">
|
||||
<option value="objet">Champ d'un objet</option>
|
||||
<option value="variable">Variable globale</option>
|
||||
</select></label>
|
||||
<label>Ligne concernée<select id="nf-cond-row"></select></label>
|
||||
<label>Champ<select id="nf-cond-field" onchange="onFlowFieldChange()"></select></label>
|
||||
|
||||
<div id="nf-cond-objet-fields" {% if not definitions %}style="display:none;"{% endif %}>
|
||||
<label>Objet<select id="nf-cond-definition" onchange="onFlowDefinitionChange('cond')">
|
||||
{% for d in definitions %}
|
||||
<option value="{{ d.id }}">{{ d.name }}</option>
|
||||
{% endfor %}
|
||||
</select></label>
|
||||
<label>Ligne concernée<select id="nf-cond-row"></select></label>
|
||||
<label>Champ<select id="nf-cond-field" onchange="onFlowFieldChange()"></select></label>
|
||||
</div>
|
||||
{% if not definitions %}
|
||||
<p class="hint" id="nf-cond-no-objet-hint">Aucun objet défini dans ce jeu pour l'instant — <a href="{{ url_for('game_dashboard', slug=game.slug, tab='objects') }}">crées-en un</a>.</p>
|
||||
{% endif %}
|
||||
|
||||
<div id="nf-cond-variable-fields" style="display:none;">
|
||||
<label>Variable<select id="nf-cond-variable">
|
||||
<option value="">(sélectionner une variable)</option>
|
||||
{% for v in global_variables %}
|
||||
<option value="{{ v.name }}">{{ v.name }}</option>
|
||||
{% endfor %}
|
||||
</select></label>
|
||||
<!-- Utile seulement si la variable choisie est de type
|
||||
"objet"/"tableau" : sans chemin, c'est toute la variable
|
||||
(son JSON) qui est comparée. -->
|
||||
<label>Chemin (optionnel)<input type="text" id="nf-cond-variable-chemin" placeholder="ex. .nom ou [0].valeur"></label>
|
||||
{% if not global_variables %}
|
||||
<p class="hint">Aucune variable globale pour l'instant — <a href="{{ url_for('game_dashboard', slug=game.slug, tab='variables') }}" target="_blank">crées-en une ↗</a></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<label>Condition<select id="nf-cond-operator">
|
||||
{% for key, label in condition_operators %}
|
||||
<option value="{{ key }}">{{ label }}</option>
|
||||
@@ -246,7 +270,6 @@
|
||||
<div id="nf-cond-extra-clauses"></div>
|
||||
<button type="button" class="smallBtn button is-small" onclick="addFlowConditionClause()">+ Ajouter une condition</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div id="nodeFormAction" style="display:none;">
|
||||
@@ -770,6 +793,46 @@
|
||||
oninput="this.nextElementSibling.textContent = 'x' + this.value">
|
||||
<span class="sliderValue tag">x{{ c.value }}</span>
|
||||
</div>
|
||||
{% elif c.type == 'clause_list' %}
|
||||
<!-- Liste de conditions ILLIMITÉE (champ/opérateur/valeur),
|
||||
combinées par ET/OU — voir c_clause_list.py. Chaque bloc
|
||||
reprend EXACTEMENT la présentation d'avant (un "Champ" et un
|
||||
"...est" clairement étiquetés, et le même sélecteur de valeur
|
||||
fixe/dynamique/variable — jamais la syntaxe "{{ }}" brute à
|
||||
lire/taper), simplement répété autant de fois que
|
||||
nécessaire. Chaque ligne existante est rendue ici (repli
|
||||
côté serveur, notamment après un rechargement de page) ;
|
||||
"+ Ajouter une condition" clone le <template> ci-dessous en
|
||||
JS pour en ajouter d'autres à la volée (voir addClauseRow()
|
||||
plus bas) — initFilterValuePickers() (déjà utilisée par
|
||||
l'ancien réglage à 2 filtres fixes, entièrement scopée par
|
||||
classe CSS via .closest('.filterValueControl'), jamais par
|
||||
id) fonctionne donc ici aussi tel quel, avec un nombre
|
||||
quelconque de blocs "valeur" sur la même page. -->
|
||||
<div class="clauseListControl" data-clause-key="{{ c.key }}">
|
||||
<div class="clauseListWrap" id="clauseListWrap-{{ c.key }}">
|
||||
{% for clause in c.value.clauses %}
|
||||
{% include "partials/clause_row.html" with context %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
<button type="button" class="button primary is-fullwidth" style="margin-top:10px;" onclick="addClauseRow('{{ c.key }}')">+ Ajouter une condition</button>
|
||||
<div class="field clauseCombinatorRow" id="clauseCombinatorRow-{{ c.key }}" style="margin-top:12px; {{ '' if c.value.clauses|length > 1 else 'display:none;' }}">
|
||||
<label class="label is-small">Combiner ces conditions avec</label>
|
||||
<div class="control">
|
||||
<div class="select is-small is-fullwidth">
|
||||
<select name="ctrl_{{ c.key }}_combinateur">
|
||||
<option value="et" {{ 'selected' if c.value.combinateur != 'ou' else '' }}>ET — toutes les conditions doivent être vraies</option>
|
||||
<option value="ou" {{ 'selected' if c.value.combinateur == 'ou' else '' }}>OU — au moins une condition suffit</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="help">Aucune condition = la ligne la plus récente de cet objet.</p>
|
||||
</div>
|
||||
{% set clause = {"champ": "", "operateur": "egal", "valeur": ""} %}
|
||||
<template id="clauseRowTemplate-{{ c.key }}">
|
||||
{% include "partials/clause_row.html" with context %}
|
||||
</template>
|
||||
{% elif c.type == 'shadow' %}
|
||||
<div class="control">
|
||||
<div class="fieldRow">
|
||||
@@ -1621,8 +1684,9 @@ function initBuilderPanel() {
|
||||
bindPropsAutosave();
|
||||
bindJaugeDefinitionSelect();
|
||||
bindDefinitionFieldSelects('field-definition_id', ['field-filtre_champ', 'field-filtre2_champ']);
|
||||
bindDefinitionFieldSelects('field-data_definition_id', ['field-data_filtre_champ', 'field-data_filtre2_champ']);
|
||||
bindDefinitionFieldSelects('field-visibilite_cond_definition_id', ['field-visibilite_cond_champ']);
|
||||
bindClauseListDefinitionSelect('field-data_definition_id');
|
||||
bindClauseListRows();
|
||||
initFilterValuePickers();
|
||||
var visCondModeSel = document.getElementById('field-visibilite_cond_mode');
|
||||
if (visCondModeSel) toggleVisCondFields(visCondModeSel);
|
||||
@@ -1701,6 +1765,88 @@ function bindDefinitionFieldSelects(defSelId, fieldSelIds) {
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- "Donnée liée" : liste de conditions ILLIMITÉE (champ/opérateur/
|
||||
// valeur), combinées entre elles par ET/OU (voir c_clause_list.py,
|
||||
// save_element_controls.py, resolve_bound_row.py). Chaque ligne existante
|
||||
// est rendue côté serveur (au chargement du panneau) ; "+ Ajouter une
|
||||
// condition" clone un <template> pour en ajouter de nouvelles à la volée,
|
||||
// sans aller-retour serveur — même principe que "fieldsList"/"varValueRows"
|
||||
// ailleurs dans l'outil. ----------
|
||||
function wireClauseRow(row, key) {
|
||||
row.querySelectorAll('input, select').forEach(function (field) {
|
||||
var debounced = field.tagName === 'INPUT';
|
||||
field.addEventListener(debounced ? 'input' : 'change', function () {
|
||||
if (debounced) debouncedSubmitPropsForm(); else submitPropsForm();
|
||||
});
|
||||
});
|
||||
var removeBtn = row.querySelector('.clauseRemoveBtn');
|
||||
if (removeBtn) {
|
||||
removeBtn.addEventListener('click', function () {
|
||||
row.remove();
|
||||
updateClauseCombinatorVisibility(key);
|
||||
submitPropsForm();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function updateClauseCombinatorVisibility(key) {
|
||||
var wrap = document.getElementById('clauseListWrap-' + key);
|
||||
var combRow = document.getElementById('clauseCombinatorRow-' + key);
|
||||
if (!wrap || !combRow) return;
|
||||
combRow.style.display = wrap.querySelectorAll('.clauseRow').length > 1 ? '' : 'none';
|
||||
}
|
||||
|
||||
function addClauseRow(key) {
|
||||
var tmpl = document.getElementById('clauseRowTemplate-' + key);
|
||||
var wrap = document.getElementById('clauseListWrap-' + key);
|
||||
if (!tmpl || !wrap) return;
|
||||
var clone = tmpl.content.cloneNode(true);
|
||||
var row = clone.querySelector('.clauseRow');
|
||||
wireClauseRow(row, key);
|
||||
wrap.appendChild(clone);
|
||||
// Repeuple tout de suite son sélecteur "Champ" avec les VRAIS champs de
|
||||
// l'objet déjà choisi (sans ça, une ligne ajoutée après coup resterait
|
||||
// bloquée sur "(sélectionner un objet d'abord)" même si un objet est
|
||||
// déjà réglé plus haut dans le panneau).
|
||||
var defSel = document.getElementById('field-data_definition_id');
|
||||
if (defSel && defSel.value) defSel.dispatchEvent(new Event('change'));
|
||||
// Initialise le sélecteur de valeur (fixe/dynamique/variable) de cette
|
||||
// nouvelle ligne — sans effet sur les lignes déjà initialisées.
|
||||
initFilterValuePickers();
|
||||
updateClauseCombinatorVisibility(key);
|
||||
submitPropsForm();
|
||||
}
|
||||
|
||||
// Rebranche les lignes déjà rendues par le serveur (rechargement du
|
||||
// panneau, ex. sélection d'un autre élément) — les lignes ajoutées EN
|
||||
// COURS de session par addClauseRow() se branchent déjà elles-mêmes.
|
||||
function bindClauseListRows() {
|
||||
document.querySelectorAll('.clauseListControl').forEach(function (control) {
|
||||
var key = control.dataset.clauseKey;
|
||||
control.querySelectorAll('.clauseRow').forEach(function (row) { wireClauseRow(row, key); });
|
||||
});
|
||||
}
|
||||
|
||||
// Repeuple le sélecteur "champ" de TOUTES les lignes de condition actuelles
|
||||
// (interrogées à chaque changement, donc couvre aussi les lignes ajoutées
|
||||
// après coup) quand on change l'objet lié — même principe que
|
||||
// bindDefinitionFieldSelects ci-dessus, généralisé pour un nombre de lignes
|
||||
// variable plutôt qu'une liste fixe d'ids capturée une fois pour toutes.
|
||||
function bindClauseListDefinitionSelect(defSelId) {
|
||||
var defSel = document.getElementById(defSelId);
|
||||
if (!defSel) return;
|
||||
defSel.addEventListener('change', function () {
|
||||
var info = (DEFINITIONS_DATA || {})[defSel.value] || { fields: [] };
|
||||
var opts = '<option value="">(sélectionner un objet d\'abord)</option>' +
|
||||
info.fields.map(function (f) { return '<option value="' + f.name + '">' + fieldOptionLabel(f) + '</option>'; }).join('');
|
||||
document.querySelectorAll('.clauseChampSelect').forEach(function (sel) {
|
||||
var current = sel.value;
|
||||
sel.innerHTML = opts;
|
||||
sel.value = current; // reste sélectionné si ce champ existe encore dans le nouvel objet
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Valeur de comparaison d'un filtre : fixe, ou donnée d'un autre
|
||||
// objet ----------
|
||||
//
|
||||
@@ -1816,6 +1962,7 @@ initBuilderPanel();
|
||||
|
||||
var DEFINITIONS_DATA = {{ definitions_json|safe }};
|
||||
var CONDITION_OPERATORS_HTML = document.getElementById('nf-cond-operator') ? document.getElementById('nf-cond-operator').innerHTML : '';
|
||||
var GLOBAL_VARIABLES_OPTIONS_HTML = document.getElementById('nf-cond-variable') ? document.getElementById('nf-cond-variable').innerHTML : '';
|
||||
|
||||
function toggleActionFields(sel) {
|
||||
var form = sel.closest('.actionAddForm');
|
||||
@@ -1952,15 +2099,19 @@ function nodeLabel(node) {
|
||||
(ELEMENTS_LABELS[node.trigger_element_id] || '?');
|
||||
}
|
||||
if (node.node_type === 'condition') {
|
||||
var mainClause = '❓ SI ' + (node.cond_field || '?') + ' ' + (CONDITION_OPERATOR_LABELS_MAP[node.cond_operator] || node.cond_operator) + ' ' + (node.cond_value || '');
|
||||
var clauseSubject = function(c, field, variable) {
|
||||
return (c.source ?? c.cond_source) === 'variable' ? ('🔧 ' + (variable || c.cond_variable || '?')) : (field || c.cond_field || '?');
|
||||
};
|
||||
var mainSubject = clauseSubject(node, node.cond_field, node.cond_variable);
|
||||
var mainClause = '❓ SI ' + mainSubject + ' ' + (CONDITION_OPERATOR_LABELS_MAP[node.cond_operator] || node.cond_operator) + ' ' + (node.cond_value || '');
|
||||
if (node.cond_clauses) {
|
||||
// 2.4 — plusieurs clauses combinées : on affiche chaque champ testé,
|
||||
// relié par le connecteur ET/OU choisi, plutôt qu'une seule clause.
|
||||
// 2.4 — plusieurs clauses combinées : on affiche chaque champ/variable
|
||||
// testé, relié par le connecteur ET/OU choisi, plutôt qu'une seule clause.
|
||||
try {
|
||||
var clauses = JSON.parse(node.cond_clauses);
|
||||
var combLabel = node.cond_combinator === 'ou' ? ' OU ' : ' ET ';
|
||||
return '❓ SI ' + clauses.map(function(c) {
|
||||
return (c.field || '?') + ' ' + (CONDITION_OPERATOR_LABELS_MAP[c.operator] || c.operator) + ' ' + (c.value || '');
|
||||
return clauseSubject(c, c.field, c.variable) + ' ' + (CONDITION_OPERATOR_LABELS_MAP[c.operator] || c.operator) + ' ' + (c.value || '');
|
||||
}).join(combLabel);
|
||||
} catch (e) { /* JSON invalide : on retombe sur la clause historique */ }
|
||||
}
|
||||
@@ -2160,6 +2311,8 @@ function openNodeForm(type) {
|
||||
document.getElementById('nodeFormAction').style.display = type === 'action' ? 'block' : 'none';
|
||||
if (type === 'trigger') toggleFlowTriggerFields();
|
||||
if (type === 'condition') {
|
||||
document.getElementById('nf-cond-source').value = 'objet';
|
||||
onFlowConditionSourceChange();
|
||||
onFlowDefinitionChange('cond');
|
||||
flowConditionExtraClauses = [];
|
||||
renderFlowConditionClauses();
|
||||
@@ -2217,6 +2370,19 @@ function onFlowFieldChange() {
|
||||
// directement sur l'option sélectionnée au moment de la création du nœud.
|
||||
}
|
||||
|
||||
// Bascule la clause PRINCIPALE d'un nœud Condition entre "Champ d'un
|
||||
// objet" (historique) et "Variable globale" — voir ensure_flow_schema.py
|
||||
// (cond_source) et evaluateConditionClause() dans play.html. Même bascule
|
||||
// pour une clause SUPPLÉMENTAIRE : voir onFlowClauseSourceChange() plus
|
||||
// bas, sur le même principe mais scopée par index.
|
||||
function onFlowConditionSourceChange() {
|
||||
var isVariable = document.getElementById('nf-cond-source').value === 'variable';
|
||||
document.getElementById('nf-cond-objet-fields').style.display = isVariable ? 'none' : 'block';
|
||||
document.getElementById('nf-cond-variable-fields').style.display = isVariable ? 'block' : 'none';
|
||||
var hint = document.getElementById('nf-cond-no-objet-hint');
|
||||
if (hint) hint.style.display = isVariable ? 'none' : 'block';
|
||||
}
|
||||
|
||||
// ---------- 2.4 — clauses supplémentaires (ET/OU) d'un nœud Condition ----------
|
||||
// Chaque clause supplémentaire réutilise EXACTEMENT le même modèle que la
|
||||
// clause principale (nf-cond-definition/row/field/operator/value) mais dans
|
||||
@@ -2226,7 +2392,10 @@ var flowConditionExtraClauses = [];
|
||||
function addFlowConditionClause() {
|
||||
var firstDefSel = document.getElementById('nf-cond-definition');
|
||||
var defaultDefId = firstDefSel && firstDefSel.value ? firstDefSel.value : '';
|
||||
flowConditionExtraClauses.push({ definition_id: defaultDefId, row_id: '', field: '', field_type: '', operator: 'egal', value: '' });
|
||||
flowConditionExtraClauses.push({
|
||||
source: 'objet', definition_id: defaultDefId, row_id: '', field: '', field_type: '',
|
||||
variable: '', variable_chemin: '', operator: 'egal', value: '',
|
||||
});
|
||||
renderFlowConditionClauses();
|
||||
}
|
||||
|
||||
@@ -2235,6 +2404,20 @@ function removeFlowConditionClause(idx) {
|
||||
renderFlowConditionClauses();
|
||||
}
|
||||
|
||||
// Bascule une clause SUPPLÉMENTAIRE (indexée) entre "Champ d'un objet" et
|
||||
// "Variable globale" — même principe que onFlowConditionSourceChange() pour
|
||||
// la clause principale, scopé par index plutôt que par id fixe.
|
||||
function onFlowClauseSourceChange(idx) {
|
||||
var sel = document.getElementById('nf-cond-extra-source-' + idx);
|
||||
if (!sel) return;
|
||||
flowConditionExtraClauses[idx].source = sel.value;
|
||||
var isVariable = sel.value === 'variable';
|
||||
var objetFields = document.getElementById('nf-cond-extra-objet-fields-' + idx);
|
||||
var varFields = document.getElementById('nf-cond-extra-variable-fields-' + idx);
|
||||
if (objetFields) objetFields.style.display = isVariable ? 'none' : 'block';
|
||||
if (varFields) varFields.style.display = isVariable ? 'block' : 'none';
|
||||
}
|
||||
|
||||
function renderFlowConditionClauses() {
|
||||
var wrap = document.getElementById('nf-cond-extra-clauses');
|
||||
var combinatorRow = document.getElementById('nf-cond-combinator-row');
|
||||
@@ -2242,11 +2425,24 @@ function renderFlowConditionClauses() {
|
||||
combinatorRow.style.display = flowConditionExtraClauses.length ? 'block' : 'none';
|
||||
var defOptionsHtml = document.getElementById('nf-cond-definition') ? document.getElementById('nf-cond-definition').innerHTML : '';
|
||||
wrap.innerHTML = flowConditionExtraClauses.map(function(clause, idx) {
|
||||
var isVariable = clause.source === 'variable';
|
||||
return '' +
|
||||
'<div class="flowConditionClauseRow" style="border-top:1px dashed #ccc; margin-top:8px; padding-top:8px;">' +
|
||||
'<label>Objet<select id="nf-cond-extra-definition-' + idx + '" onchange="onFlowClauseDefinitionChange(' + idx + ')">' + defOptionsHtml + '</select></label>' +
|
||||
'<label>Ligne concernée<select id="nf-cond-extra-row-' + idx + '" onchange="flowConditionExtraClauses[' + idx + '].row_id = this.value"></select></label>' +
|
||||
'<label>Champ<select id="nf-cond-extra-field-' + idx + '" onchange="onFlowClauseFieldChange(' + idx + ')"></select></label>' +
|
||||
'<label>Source<select id="nf-cond-extra-source-' + idx + '" onchange="onFlowClauseSourceChange(' + idx + ')">' +
|
||||
'<option value="objet"' + (isVariable ? '' : ' selected') + '>Champ d\'un objet</option>' +
|
||||
'<option value="variable"' + (isVariable ? ' selected' : '') + '>Variable globale</option>' +
|
||||
'</select></label>' +
|
||||
'<div id="nf-cond-extra-objet-fields-' + idx + '" style="display:' + (isVariable ? 'none' : 'block') + ';">' +
|
||||
'<label>Objet<select id="nf-cond-extra-definition-' + idx + '" onchange="onFlowClauseDefinitionChange(' + idx + ')">' + defOptionsHtml + '</select></label>' +
|
||||
'<label>Ligne concernée<select id="nf-cond-extra-row-' + idx + '" onchange="flowConditionExtraClauses[' + idx + '].row_id = this.value"></select></label>' +
|
||||
'<label>Champ<select id="nf-cond-extra-field-' + idx + '" onchange="onFlowClauseFieldChange(' + idx + ')"></select></label>' +
|
||||
'</div>' +
|
||||
'<div id="nf-cond-extra-variable-fields-' + idx + '" style="display:' + (isVariable ? 'block' : 'none') + ';">' +
|
||||
'<label>Variable<select id="nf-cond-extra-variable-' + idx + '" onchange="flowConditionExtraClauses[' + idx + '].variable = this.value">' +
|
||||
'<option value="">(sélectionner une variable)</option>' + GLOBAL_VARIABLES_OPTIONS_HTML +
|
||||
'</select></label>' +
|
||||
'<label>Chemin (optionnel)<input type="text" id="nf-cond-extra-variable-chemin-' + idx + '" placeholder="ex. .nom ou [0].valeur" value="' + (clause.variable_chemin || '').replace(/"/g, '"') + '" onchange="flowConditionExtraClauses[' + idx + '].variable_chemin = this.value"></label>' +
|
||||
'</div>' +
|
||||
'<label>Condition<select id="nf-cond-extra-operator-' + idx + '" onchange="flowConditionExtraClauses[' + idx + '].operator = this.value">' +
|
||||
CONDITION_OPERATORS_HTML +
|
||||
'</select></label>' +
|
||||
@@ -2260,6 +2456,8 @@ function renderFlowConditionClauses() {
|
||||
onFlowClauseDefinitionChange(idx);
|
||||
var opSel = document.getElementById('nf-cond-extra-operator-' + idx);
|
||||
if (opSel) opSel.value = clause.operator || 'egal';
|
||||
var varSel = document.getElementById('nf-cond-extra-variable-' + idx);
|
||||
if (varSel && clause.variable) varSel.value = clause.variable;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2362,26 +2560,35 @@ function submitNodeForm() {
|
||||
payload.trigger_element_id = parseInt(document.getElementById('nf-trigger-element').value, 10);
|
||||
}
|
||||
} else if (currentNodeFormType === 'condition') {
|
||||
var fieldSel = document.getElementById('nf-cond-field');
|
||||
var fieldOpt = fieldSel.options[fieldSel.selectedIndex];
|
||||
payload.cond_definition_id = parseInt(document.getElementById('nf-cond-definition').value, 10);
|
||||
payload.cond_row_id = parseInt(document.getElementById('nf-cond-row').value, 10) || null;
|
||||
payload.cond_field = fieldSel.value;
|
||||
payload.cond_field_type = fieldOpt ? fieldOpt.dataset.type : '';
|
||||
var condSource = document.getElementById('nf-cond-source').value;
|
||||
payload.cond_source = condSource;
|
||||
payload.cond_operator = document.getElementById('nf-cond-operator').value;
|
||||
payload.cond_value = document.getElementById('nf-cond-value').value;
|
||||
if (condSource === 'variable') {
|
||||
payload.cond_variable = document.getElementById('nf-cond-variable').value;
|
||||
payload.cond_variable_chemin = document.getElementById('nf-cond-variable-chemin').value;
|
||||
} else {
|
||||
var fieldSel = document.getElementById('nf-cond-field');
|
||||
var fieldOpt = fieldSel.options[fieldSel.selectedIndex];
|
||||
payload.cond_definition_id = parseInt(document.getElementById('nf-cond-definition').value, 10);
|
||||
payload.cond_row_id = parseInt(document.getElementById('nf-cond-row').value, 10) || null;
|
||||
payload.cond_field = fieldSel.value;
|
||||
payload.cond_field_type = fieldOpt ? fieldOpt.dataset.type : '';
|
||||
}
|
||||
if (flowConditionExtraClauses.length) {
|
||||
// 2.4 — clauses combinées : la clause "historique" ci-dessus devient
|
||||
// la première clause de la liste, suivie des clauses supplémentaires.
|
||||
var allClauses = [{
|
||||
definition_id: payload.cond_definition_id, row_id: payload.cond_row_id,
|
||||
field: payload.cond_field, field_type: payload.cond_field_type,
|
||||
operator: payload.cond_operator, value: payload.cond_value
|
||||
}].concat(flowConditionExtraClauses.map(function(c) {
|
||||
return {
|
||||
definition_id: parseInt(c.definition_id, 10), row_id: parseInt(c.row_id, 10) || null,
|
||||
field: c.field, field_type: c.field_type, operator: c.operator, value: c.value
|
||||
};
|
||||
var mainClause = condSource === 'variable'
|
||||
? { source: 'variable', variable: payload.cond_variable, variable_chemin: payload.cond_variable_chemin,
|
||||
operator: payload.cond_operator, value: payload.cond_value }
|
||||
: { source: 'objet', definition_id: payload.cond_definition_id, row_id: payload.cond_row_id,
|
||||
field: payload.cond_field, field_type: payload.cond_field_type,
|
||||
operator: payload.cond_operator, value: payload.cond_value };
|
||||
var allClauses = [mainClause].concat(flowConditionExtraClauses.map(function(c) {
|
||||
return c.source === 'variable'
|
||||
? { source: 'variable', variable: c.variable, variable_chemin: c.variable_chemin, operator: c.operator, value: c.value }
|
||||
: { source: 'objet', definition_id: parseInt(c.definition_id, 10), row_id: parseInt(c.row_id, 10) || null,
|
||||
field: c.field, field_type: c.field_type, operator: c.operator, value: c.value };
|
||||
}));
|
||||
payload.cond_clauses = JSON.stringify(allClauses);
|
||||
payload.cond_combinator = document.getElementById('nf-cond-combinator').value;
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Un nœud Condition du graphe de logique peut désormais tester une
|
||||
VARIABLE GLOBALE, pas seulement un champ d'objet (voir
|
||||
ensure_flow_schema.py : cond_source/cond_variable/cond_variable_chemin).
|
||||
Évalué côté CLIENT (templates/play.html, evaluateConditionClause) contre
|
||||
gameData.variables — exposé par full_game_payload.py, vérifié ici via
|
||||
/runtime-payload. La logique JS elle-même (résolution de chemin, lecture
|
||||
de gameData.variables, repli sur le comportement historique) a été vérifiée
|
||||
manuellement avec Node.js (fonctions extraites de play.html) : variable
|
||||
scalaire, objet avec chemin chaîné, tableau par index, variable
|
||||
introuvable (ne matche rien plutôt que planter), booléen "Oui", et clause
|
||||
sans "source" (comportement legacy, champ d'objet) — les 7 scénarios
|
||||
passent."""
|
||||
import json
|
||||
import re
|
||||
|
||||
|
||||
def _create_screen(client, slug, name="Accueil"):
|
||||
resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False)
|
||||
return int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1))
|
||||
|
||||
|
||||
def test_condition_node_stores_variable_source(client, game):
|
||||
client.post(f"/game/{game}/variables/new", data={"name": "score", "type": "nombre_entier", "default_value": "5"})
|
||||
screen_id = _create_screen(client, game)
|
||||
|
||||
resp = client.post(
|
||||
f"/game/{game}/screens/{screen_id}/flow/nodes/add",
|
||||
data=json.dumps({
|
||||
"node_type": "condition", "cond_source": "variable",
|
||||
"cond_variable": "score", "cond_variable_chemin": "",
|
||||
"cond_operator": "superieur_egal", "cond_value": "3",
|
||||
}),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
node = resp.get_json()
|
||||
assert node["cond_source"] == "variable"
|
||||
assert node["cond_variable"] == "score"
|
||||
assert node["cond_operator"] == "superieur_egal"
|
||||
# Aucun champ d'objet renseigné pour une clause "variable".
|
||||
assert node["cond_definition_id"] is None
|
||||
assert node["cond_field"] is None
|
||||
|
||||
|
||||
def test_condition_node_with_combined_variable_and_object_clauses(client, game):
|
||||
client.post(f"/game/{game}/variables/new", data={"name": "score", "type": "nombre_entier", "default_value": "5"})
|
||||
resp = client.post(f"/game/{game}/objects/new", data={
|
||||
"object_name": "Niveau", "field_name[]": ["numero"], "field_type[]": ["nombre_entier"],
|
||||
"field_relation[]": [""], "field_required[]": ["0"], "field_min[]": [""], "field_max[]": [""],
|
||||
}, follow_redirects=False)
|
||||
def_id = int(resp.headers["Location"].rstrip("/").split("/")[-1])
|
||||
screen_id = _create_screen(client, game)
|
||||
|
||||
clauses = [
|
||||
{"source": "variable", "variable": "score", "operator": "superieur_egal", "value": "3"},
|
||||
{"source": "objet", "definition_id": def_id, "row_id": None, "field": "numero",
|
||||
"field_type": "nombre_entier", "operator": "egal", "value": "1"},
|
||||
]
|
||||
resp = client.post(
|
||||
f"/game/{game}/screens/{screen_id}/flow/nodes/add",
|
||||
data=json.dumps({
|
||||
"node_type": "condition", "cond_clauses": json.dumps(clauses), "cond_combinator": "ou",
|
||||
}),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
node = resp.get_json()
|
||||
stored = json.loads(node["cond_clauses"])
|
||||
assert stored[0]["source"] == "variable"
|
||||
assert stored[1]["source"] == "objet"
|
||||
assert node["cond_combinator"] == "ou"
|
||||
|
||||
|
||||
def test_runtime_payload_exposes_global_variables_for_condition_evaluation(client, game):
|
||||
client.post(f"/game/{game}/variables/new", data={"name": "score", "type": "nombre_entier", "default_value": "5"})
|
||||
client.post(f"/game/{game}/variables/new", data={"name": "perso", "type": "objet",
|
||||
"default_value": json.dumps({"nom": "Alice"})})
|
||||
payload = client.get(f"/game/{game}/runtime-payload").get_json()
|
||||
assert payload["variables"]["score"] == {"value": "5", "type": "nombre_entier"}
|
||||
assert payload["variables"]["perso"]["type"] == "objet"
|
||||
assert json.loads(payload["variables"]["perso"]["value"]) == {"nom": "Alice"}
|
||||
|
||||
|
||||
def test_screen_edit_panel_exposes_variable_source_condition_ui(client, game):
|
||||
client.post(f"/game/{game}/variables/new", data={"name": "score", "type": "nombre_entier", "default_value": "5"})
|
||||
screen_id = _create_screen(client, game)
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit").get_data(as_text=True)
|
||||
assert 'id="nf-cond-source"' in html
|
||||
assert 'id="nf-cond-variable-fields"' in html
|
||||
assert "onFlowConditionSourceChange" in html
|
||||
assert "onFlowClauseSourceChange" in html
|
||||
|
||||
|
||||
def test_condition_builder_still_available_without_any_object_defined(client, game):
|
||||
"""Régression : le panneau de condition était entièrement masqué quand
|
||||
le jeu n'avait AUCUN objet ("Aucun objet défini..."), rendant
|
||||
impossible de créer une condition sur une variable dans ce cas. Seule
|
||||
la partie "Champ d'un objet" doit rester masquée ; "Variable globale"
|
||||
doit rester utilisable."""
|
||||
client.post(f"/game/{game}/variables/new", data={"name": "score", "type": "nombre_entier", "default_value": "5"})
|
||||
screen_id = _create_screen(client, game)
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit").get_data(as_text=True)
|
||||
assert 'id="nf-cond-source"' in html
|
||||
assert 'id="nf-cond-variable-fields"' in html
|
||||
@@ -203,6 +203,37 @@ def test_overlay_box_default_text_color_survives_the_bulma_box_class(client, gam
|
||||
assert "color:#e8eaf0" in box_tag
|
||||
|
||||
|
||||
def test_visibility_dropdown_reflects_masque_even_when_visible_would_also_match(client, game):
|
||||
"""Régression (la vraie cause derrière "je ne vois plus ma modale, ni
|
||||
même en changeant Visibilité sur Visible") : le panneau de propriétés
|
||||
détecte la valeur ACTUELLE d'un réglage "preset" (screens/widgets/
|
||||
control_value.py) en cherchant la PREMIÈRE option de la liste dont les
|
||||
critères correspondent au style stocké. L'option "Visible normalement"
|
||||
de la Visibilité (visibility_control.py) ne vérifie QUE "visibility"
|
||||
(jamais "display", par choix assumé) — un élément "Masqué" (qui ne pose
|
||||
que "display:none", jamais "visibility") satisfaisait donc TOUJOURS,
|
||||
trivialement, les critères de "Visible" en premier (testée avant
|
||||
"Masqué" dans la liste) : le panneau affichait "Visible normalement"
|
||||
sélectionné sur un élément EN RÉALITÉ masqué. Comme un <select> ne
|
||||
déclenche un enregistrement que sur un changement RÉEL de valeur,
|
||||
re-choisir l'option déjà affichée ne faisait RIEN : impossible de
|
||||
rendre l'élément visible depuis le panneau. Corrigé en vérifiant les
|
||||
options les plus SPÉCIFIQUES (le plus de propriétés non vides exigées)
|
||||
en premier."""
|
||||
from screens.widgets.control_value import _control_value
|
||||
from screens.widgets.widget_meta import widget_meta
|
||||
import screens
|
||||
|
||||
screen_id = _create_screen(client, game)
|
||||
overlay_id = _add_element(client, game, screen_id, "superposition")
|
||||
el = screens.get_element(game, overlay_id)
|
||||
meta = widget_meta(el["widget"])
|
||||
vis_control = next(c for c in meta["controls"] if c["key"] == "visibilite")
|
||||
# "Masqué" par défaut à la création (default_style_for_widget.py) : le
|
||||
# panneau doit détecter "masque", pas "visible".
|
||||
assert _control_value(el, vis_control) == "masque"
|
||||
|
||||
|
||||
def test_overlay_behaves_like_a_normal_container_in_the_editor(client, game):
|
||||
"""Régression (deux retours utilisateur successifs) :
|
||||
1. Un essai précédent forçait "display:flex" dans l'éditeur, quel que
|
||||
@@ -284,6 +315,55 @@ def test_overlay_element_type_instance_has_no_visible_wrapper_box(client, game):
|
||||
assert "z-index" not in wrapper_style
|
||||
|
||||
|
||||
def test_overlay_element_type_instance_is_controllable_from_the_hosting_scene(client, game):
|
||||
"""Régression : la logique posée sur LA SCÈNE qui accueille un
|
||||
exemplaire de dialogue (ex. "Modifier un élément → Modale : Visibilité
|
||||
= Rendre visible", ciblant l'exemplaire par son id SUR CETTE SCÈNE)
|
||||
n'avait plus aucun effet une fois l'enveloppe "conteneur" entièrement
|
||||
court-circuitée (un essai précédent) : son id disparaissait du DOM
|
||||
(impossible à cibler), et même en le gardant, la superposition INTERNE
|
||||
au modèle restait masquée indépendamment (double masquage — rendre
|
||||
l'enveloppe visible n'aurait rien changé). Corrigé : l'enveloppe
|
||||
GARDE son propre id/data-element-id (ciblable depuis la scène), et la
|
||||
superposition interne au modèle ignore désormais son propre réglage
|
||||
"Visibilité" une fois posée comme exemplaire — tout le masquage est
|
||||
délégué à l'enveloppe (qui démarre elle-même masquée par défaut, voir
|
||||
add_element.py)."""
|
||||
import screens
|
||||
resp = client.post(f"/game/{game}/element-types", data={"name": "Dialogue2"}, follow_redirects=False)
|
||||
et = next(t for t in screens.list_element_types(game) if t["name"] == "Dialogue2")
|
||||
overlay_id = _add_element(client, game, et["template_screen_id"], "superposition")
|
||||
client.post(f"/game/{game}/elements/{overlay_id}/children/add", data={"widget": "texte"})
|
||||
|
||||
screen_id = _create_screen(client, game, "Scène2")
|
||||
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add",
|
||||
data={"widget": "__catalogue__", "element_type_id": et["id"]}, follow_redirects=False)
|
||||
instance_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
|
||||
html = client.get(f"/game/{game}/play").data.decode()
|
||||
idx = html.find(f'data-element-id="{instance_id}"')
|
||||
assert idx != -1
|
||||
wrapper_tag = html[html.rfind("<div", 0, idx):idx + 40]
|
||||
assert "display:none" in wrapper_tag # masqué par défaut, sur l'EXEMPLAIRE
|
||||
|
||||
# La superposition interne au modèle, elle, ne doit PLUS porter son
|
||||
# propre display:none une fois rendue comme exemplaire (sinon la
|
||||
# rendre visible depuis la scène resterait sans effet).
|
||||
idx_modal = html.find('class="modal is-active"', idx)
|
||||
modal_tag = html[html.rfind("<div", 0, idx_modal):idx_modal + 250]
|
||||
assert "display:none" not in modal_tag
|
||||
|
||||
trig = client.post(f"/game/{game}/screens/{screen_id}/flow/nodes/add",
|
||||
data=json.dumps({"node_type": "trigger", "trigger_event": "affichage"}),
|
||||
content_type="application/json").get_json()
|
||||
act = client.post(f"/game/{game}/screens/{screen_id}/flow/nodes/add",
|
||||
data=json.dumps({
|
||||
"node_type": "action", "action_type": "modifier_element",
|
||||
"target_element_id": instance_id, "element_property": "visibilite", "element_value": "visible",
|
||||
}), content_type="application/json").get_json()
|
||||
assert act["target_element_id"] == instance_id
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
""""Donnée liée" (data_binding_controls.py) : liste de conditions
|
||||
ILLIMITÉE, combinées par ET ou par OU, plutôt que les 2 filtres fixes
|
||||
d'avant (toujours combinés en ET) — même principe que les conditions
|
||||
combinées du graphe de logique (cond_clauses/cond_combinator).
|
||||
Rétrocompatible avec les anciens éléments (_data_filtre_champ/
|
||||
_data_filtre2_champ), convertis à la volée en liste de clauses à la
|
||||
lecture (screens/clause_list_codec.py), sans migration de données."""
|
||||
import json
|
||||
import re
|
||||
|
||||
import db
|
||||
import screens
|
||||
|
||||
|
||||
def _create_screen(client, slug, name="Accueil"):
|
||||
resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False)
|
||||
return int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1))
|
||||
|
||||
|
||||
def _create_niveau_object(client, slug):
|
||||
resp = client.post(f"/game/{slug}/objects/new", data={
|
||||
"object_name": "Niveau", "field_name[]": ["numero", "parcours"],
|
||||
"field_type[]": ["nombre_entier", "texte"],
|
||||
"field_relation[]": ["", ""], "field_required[]": ["0", "0"],
|
||||
"field_min[]": ["", ""], "field_max[]": ["", ""],
|
||||
}, follow_redirects=False)
|
||||
def_id = int(resp.headers["Location"].rstrip("/").split("/")[-1])
|
||||
client.post(f"/game/{slug}/objects/{def_id}/data/new", data={"numero": "1", "parcours": "A"})
|
||||
client.post(f"/game/{slug}/objects/{def_id}/data/new", data={"numero": "2", "parcours": "A"})
|
||||
client.post(f"/game/{slug}/objects/{def_id}/data/new", data={"numero": "3", "parcours": "B"})
|
||||
return def_id
|
||||
|
||||
|
||||
def _rendered_text(client, slug):
|
||||
payload = client.get(f"/game/{slug}/runtime-payload").get_json()
|
||||
html = payload["screens"][0]["elements"][0]["rendered_html"]
|
||||
return re.sub(r"<[^>]+>", "", html)
|
||||
|
||||
|
||||
def test_three_clauses_combined_with_et(client, game):
|
||||
def_id = _create_niveau_object(client, game)
|
||||
screen_id = _create_screen(client, game)
|
||||
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "texte"}, follow_redirects=False)
|
||||
el_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
|
||||
client.post(f"/game/{game}/elements/{el_id}/save", data={
|
||||
"ctrl_content": "{{numero}}",
|
||||
"ctrl_data_definition_id": str(def_id),
|
||||
"ctrl_data_filtres_champ[]": ["parcours", "numero"],
|
||||
"ctrl_data_filtres_operateur[]": ["egal", "superieur"],
|
||||
"ctrl_data_filtres_valeur[]": ["A", "1"],
|
||||
"ctrl_data_filtres_combinateur": "et",
|
||||
})
|
||||
# parcours == A ET numero > 1 -> seule la ligne #2 correspond.
|
||||
assert _rendered_text(client, game) == "2"
|
||||
|
||||
|
||||
def test_clauses_combined_with_ou(client, game):
|
||||
def_id = _create_niveau_object(client, game)
|
||||
screen_id = _create_screen(client, game)
|
||||
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "texte"}, follow_redirects=False)
|
||||
el_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
|
||||
client.post(f"/game/{game}/elements/{el_id}/save", data={
|
||||
"ctrl_content": "{{numero}}",
|
||||
"ctrl_data_definition_id": str(def_id),
|
||||
"ctrl_data_filtres_champ[]": ["parcours", "numero"],
|
||||
"ctrl_data_filtres_operateur[]": ["egal", "egal"],
|
||||
"ctrl_data_filtres_valeur[]": ["B", "999"],
|
||||
"ctrl_data_filtres_combinateur": "ou",
|
||||
})
|
||||
# parcours == B OU numero == 999 -> seule la ligne #3 (parcours B) matche,
|
||||
# la plus récente d'abord (ORDER BY id DESC) donc c'est bien elle.
|
||||
assert _rendered_text(client, game) == "3"
|
||||
|
||||
|
||||
def test_clauses_stored_as_json_and_empty_rows_ignored(client, game):
|
||||
def_id = _create_niveau_object(client, game)
|
||||
screen_id = _create_screen(client, game)
|
||||
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "texte"}, follow_redirects=False)
|
||||
el_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
|
||||
client.post(f"/game/{game}/elements/{el_id}/save", data={
|
||||
"ctrl_data_definition_id": str(def_id),
|
||||
# Une ligne vide (ajoutée par erreur, jamais remplie) ne doit pas
|
||||
# compter comme une vraie condition.
|
||||
"ctrl_data_filtres_champ[]": ["numero", ""],
|
||||
"ctrl_data_filtres_operateur[]": ["egal", "egal"],
|
||||
"ctrl_data_filtres_valeur[]": ["2", ""],
|
||||
"ctrl_data_filtres_combinateur": "et",
|
||||
})
|
||||
el = screens.get_element(game, el_id)
|
||||
clauses = json.loads(el["attributes"]["_data_filtres_json"])
|
||||
assert clauses == [{"champ": "numero", "operateur": "egal", "valeur": "2"}]
|
||||
# combinateur "et" est la valeur par défaut : pas figé en base.
|
||||
assert "_data_filtre_combinateur" not in el["attributes"]
|
||||
|
||||
|
||||
def test_no_clause_falls_back_to_most_recent_row(client, game):
|
||||
def_id = _create_niveau_object(client, game)
|
||||
screen_id = _create_screen(client, game)
|
||||
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "texte"}, follow_redirects=False)
|
||||
el_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
|
||||
client.post(f"/game/{game}/elements/{el_id}/save", data={
|
||||
"ctrl_content": "{{numero}}", "ctrl_data_definition_id": str(def_id),
|
||||
})
|
||||
assert _rendered_text(client, game) == "3"
|
||||
|
||||
|
||||
def test_legacy_two_fixed_filters_still_resolve_via_fallback(client, game):
|
||||
"""Un élément créé AVANT cette fonctionnalité (attr:_data_filtre_champ/
|
||||
_data_filtre2_champ, jamais _data_filtres_json) doit continuer à
|
||||
fonctionner sans migration de données."""
|
||||
def_id = _create_niveau_object(client, game)
|
||||
screen_id = _create_screen(client, game)
|
||||
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "texte"}, follow_redirects=False)
|
||||
el_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
|
||||
conn = db.connect(game)
|
||||
conn.execute(
|
||||
"UPDATE _screen_elements SET attributes = ?, content = ? WHERE id = ?",
|
||||
(json.dumps({
|
||||
"_data_definition_id": str(def_id),
|
||||
"_data_filtre_champ": "parcours", "_data_filtre_operateur": "egal", "_data_filtre_valeur": "A",
|
||||
"_data_filtre2_champ": "numero", "_data_filtre2_operateur": "egal", "_data_filtre2_valeur": "2",
|
||||
}), "{{numero}}", el_id),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
# Ancien format : toujours combiné en ET -> parcours==A ET numero==2.
|
||||
assert _rendered_text(client, game) == "2"
|
||||
|
||||
# Le panneau reconstruit une clause éditable par ancien filtre non vide.
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit?selected={el_id}").get_data(as_text=True)
|
||||
assert 'name="ctrl_data_filtres_valeur[]" value="A"' in html
|
||||
assert 'name="ctrl_data_filtres_valeur[]" value="2"' in html
|
||||
|
||||
|
||||
def test_properties_panel_renders_scrollable_clause_list(client, game):
|
||||
def_id = _create_niveau_object(client, game)
|
||||
screen_id = _create_screen(client, game)
|
||||
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "texte"}, follow_redirects=False)
|
||||
el_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
client.post(f"/game/{game}/elements/{el_id}/save", data={"ctrl_data_definition_id": str(def_id)})
|
||||
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit?selected={el_id}").get_data(as_text=True)
|
||||
assert 'id="clauseListWrap-data_filtres"' in html
|
||||
assert "addClauseRow(" in html
|
||||
assert "clauseRowTemplate-data_filtres" in html
|
||||
Reference in New Issue
Block a user