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
@@ -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