Donnée liée : conditions ET/OU illimitées + conditions de logique sur une variable globale
Deux fonctionnalités demandées, développées et corrigées dans cet
échange :
1. "Donnée liée" (Texte/Titre) : le réglage à 2 filtres fixes (toujours
combinés en ET) devient une liste de conditions ILLIMITÉE, avec un
choix ET/OU pour les combiner (screens/widgets/controls/c_clause_list.py,
screens/clause_list_codec.py). Rétrocompatible avec les anciens
éléments (_data_filtre_champ/_data_filtre2_champ), convertis à la
volée à la lecture, sans migration. Après un premier essai à la
présentation trop compacte et technique (retour utilisateur : "pas de
champ technique, pas de notation bizarre {{ }}"), la présentation
finale reprend EXACTEMENT l'ancien style (labels "Champ"/"...est"/
"...cette valeur", même sélecteur de valeur fixe/dynamique/variable
déjà existant, jamais la syntaxe brute), simplement répétée par
condition (templates/partials/clause_row.html), avec un bouton
"+ Ajouter une condition" bien visible et une liste scrollable
(static/style.css, .clauseListWrap). Le même moteur (filter_repeater_
rows.py généralisé) profite aussi au Répéteur de données en interne.
2. Nœud Condition de la Logique de la scène : peut désormais tester une
VARIABLE GLOBALE en plus d'un champ d'objet (cond_source/cond_variable/
cond_variable_chemin — screens/flow/ensure_flow_schema.py), sur la
clause principale ET chaque clause supplémentaire (ET/OU). Évalué côté
CLIENT (templates/play.html, evaluateConditionClause), contre un
nouveau gameData.variables exposé par full_game_payload.py — tenu à
jour par refreshRuntimeData() après toute action qui modifie une
variable, sans changement supplémentaire nécessaire. Le panneau de
condition reste utilisable même sans aucun objet défini dans le jeu
(avant, il disparaissait entièrement).
Vérifié : 153 tests pytest (nouveaux : test_data_binding_clause_list.py,
test_condition_variable.py) + logique JS d'évaluation des conditions
vérifiée isolément avec Node (variable scalaire, objet avec chemin
chaîné, tableau par index, variable introuvable, booléen, rétrocompatibilité
legacy) + rendu des deux pages (éditeur/jeu) vérifié sur le vrai projet
"test" en plus des jeux de test.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
7c237d6f1c
commit
8cffbeac68
@@ -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) {
|
||||
|
||||
+227
-20
@@ -219,9 +219,12 @@
|
||||
</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>Source<select id="nf-cond-source" onchange="onFlowConditionSourceChange()">
|
||||
<option value="objet">Champ d'un objet</option>
|
||||
<option value="variable">Variable globale</option>
|
||||
</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>
|
||||
@@ -229,6 +232,27 @@
|
||||
</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>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 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 : '';
|
||||
payload.cond_operator = document.getElementById('nf-cond-operator').value;
|
||||
payload.cond_value = document.getElementById('nf-cond-value').value;
|
||||
}
|
||||
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,
|
||||
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
|
||||
}].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
|
||||
};
|
||||
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