Dev #1

Merged
w-vandal merged 62 commits from dev into main 2026-08-26 04:56:44 +00:00
3 changed files with 146 additions and 8 deletions
Showing only changes of commit ff01b3903c - Show all commits
+1 -1
View File
@@ -47,7 +47,7 @@ def render_element_html(el, slug=None, children_map=None, ctx=None, parent_flex_
# ce cas d'office.
has_visibility_condition = attrs_raw.get("_visibilite_cond_mode") in ("variable", "objet")
play_mode = bool((ctx or {}).get("_forge_play_mode"))
if play_mode and has_visibility_condition and not is_element_visible(slug, attrs_raw):
if play_mode and has_visibility_condition and not is_element_visible(slug, attrs_raw, ctx):
return f'<div class="visibilityGated" data-element-id="{el["id"]}" style="display:none;"></div>'
def _mark(html_str):
+23 -7
View File
@@ -3,17 +3,29 @@ import db
from .filter_repeater_rows import _resolve_filter_value, _compare, _field_column
def is_element_visible(slug, attrs):
def is_element_visible(slug, attrs, ctx=None):
"""Condition de visibilité (panneau de propriétés, tout widget — voir
VISIBILITY_CONDITION_CONTROLS) : masque un élément selon soit une
VARIABLE GLOBALE (une seule valeur pour tout le jeu, voir
db/global_vars/), soit le champ d'un objet de données (convention "1
seule ligne = état de partie", déjà utilisée par la Jauge/Donnée liée).
Absente de réglage (_visibilite_cond_mode vide) => toujours visible,
comportement inchangé pour tous les éléments déjà créés. Réutilise
db/global_vars/), soit le champ d'un objet de données. Absente de
réglage (_visibilite_cond_mode vide) => toujours visible, comportement
inchangé pour tous les éléments déjà créés. Réutilise
_compare/_resolve_filter_value/_field_column de filter_repeater_rows.py
pour rester cohérent avec les mêmes opérateurs et la même syntaxe
"{{Objet.champ}}" pour une valeur de comparaison dynamique."""
"{{Objet.champ}}" pour une valeur de comparaison dynamique.
`ctx` porte les {{champ}} de la ligne en cours (voir render_repeater.py)
quand cet élément est descendant d'un Répéteur de données — en mode
"objet", si le champ réglé s'y trouve déjà, sa valeur est utilisée telle
quelle plutôt que d'aller chercher la ligne la plus récente de l'objet
en base : sans ça, TOUS les exemplaires d'un même modèle de ligne (ex.
une "Mail card" répétée pour chaque e-mail) évalueraient la même ligne
(la plus récente de l'objet) au lieu de chacun la sienne, et
n'afficheraient donc jamais rien de cohérent. Hors Répéteur (mode
"objet" sur un élément posé directement sur l'écran), le champ n'est
pas dans ctx et on retombe sur la convention "1 seule ligne = état de
partie" (déjà utilisée par la Jauge/Donnée liée) : la ligne la plus
récente de l'objet choisi."""
mode = attrs.get("_visibilite_cond_mode")
if mode not in ("variable", "objet"):
return True
@@ -36,9 +48,13 @@ def is_element_visible(slug, attrs):
definition = db.get_definition(slug, int(definition_id))
if not definition:
return True
field_type = next((f["type"] for f in definition["fields"] if f["name"] == field_name), "texte")
if ctx and field_name in ctx:
return _compare(ctx.get(field_name), operator, expected, field_type)
rows = db.list_rows(slug, definition)
if not rows:
return True # objet sans donnée pour l'instant : ne bloque pas
row = rows[0] # convention "état de partie" : une seule ligne, voir data_definition_options.py
field_type = next((f["type"] for f in definition["fields"] if f["name"] == field_name), "texte")
return _compare(row.get(_field_column(definition, field_name)), operator, expected, field_type)
+122
View File
@@ -0,0 +1,122 @@
"""Tests de la "condition de visibilité" (panneau de propriétés, tout
widget) : masque un élément en mode jouable selon une variable globale ou
le champ d'un objet de données. Jamais appliquée dans l'éditeur."""
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 _add_element(client, slug, screen_id, widget="texte"):
resp = client.post(f"/game/{slug}/screens/{screen_id}/elements/add", data={"widget": widget}, follow_redirects=False)
return int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
def _rendered_html(client, slug):
payload = client.get(f"/game/{slug}/runtime-payload").get_json()
return payload["screens"][0]["elements"][0]["rendered_html"]
def test_variable_mode_hides_and_shows_element(client, game):
screen_id = _create_screen(client, game)
element_id = _add_element(client, game, screen_id)
client.post(f"/game/{game}/variables/new", data={"name": "porte_ouverte", "type": "booleen", "default_value": "0"})
client.post(f"/game/{game}/elements/{element_id}/save", data={
"ctrl_visibilite_cond_mode": "variable",
"ctrl_visibilite_cond_variable": "porte_ouverte",
"ctrl_visibilite_cond_operateur": "egal",
"ctrl_visibilite_cond_valeur": "1",
})
html = _rendered_html(client, game)
assert "display:none" in html
db.update_global_variable_value(game, "porte_ouverte", "1")
html = _rendered_html(client, game)
assert "display:none" not in html
def test_visibility_condition_never_hides_in_the_editor(client, game):
screen_id = _create_screen(client, game)
element_id = _add_element(client, game, screen_id)
client.post(f"/game/{game}/variables/new", data={"name": "porte_ouverte", "type": "booleen", "default_value": "0"})
client.post(f"/game/{game}/elements/{element_id}/save", data={
"ctrl_visibilite_cond_mode": "variable",
"ctrl_visibilite_cond_variable": "porte_ouverte",
"ctrl_visibilite_cond_operateur": "egal",
"ctrl_visibilite_cond_valeur": "1",
})
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
assert 'class="visibilityGated"' not in html
assert f'id="el-{element_id}"' in html
def test_objet_mode_uses_most_recent_row_outside_a_repeater(client, game):
screen_id = _create_screen(client, game)
element_id = _add_element(client, game, screen_id)
resp = client.post(f"/game/{game}/objects/new", data={
"object_name": "Partie", "field_name[]": ["score"], "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])
client.post(f"/game/{game}/objects/{def_id}/data/new", data={"score": "3"})
client.post(f"/game/{game}/elements/{element_id}/save", data={
"ctrl_visibilite_cond_mode": "objet",
"ctrl_visibilite_cond_definition_id": str(def_id),
"ctrl_visibilite_cond_champ": "score",
"ctrl_visibilite_cond_operateur": "superieur",
"ctrl_visibilite_cond_valeur": "10",
})
assert "display:none" in _rendered_html(client, game)
client.post(f"/game/{game}/objects/{def_id}/data/1/edit", data={"score": "20"})
assert "display:none" not in _rendered_html(client, game)
def test_objet_mode_inside_a_repeater_evaluates_each_row_independently(client, game):
"""Régression : une "Mail card" (élément de jeu réutilisable posé dans
un Répéteur d'e-mails) doit afficher l'icône "ouvert"/"fermé" qui
correspond à SA PROPRE ligne — pas toujours la même ligne (la plus
récente de l'objet) pour tous les exemplaires du Répéteur."""
resp = client.post(f"/game/{game}/objects/new", data={
"object_name": "Email", "field_name[]": ["sujet", "is_opened"], "field_type[]": ["texte", "booleen"],
"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/{game}/objects/{def_id}/data/new", data={"sujet": "Fermé"})
client.post(f"/game/{game}/objects/{def_id}/data/new", data={"sujet": "Ouvert", "is_opened": "on"})
resp = client.post(f"/game/{game}/element-types", data={"name": "Mail card", "icon": "📧"}, follow_redirects=False)
et = next(t for t in screens.list_element_types(game) if t["name"] == "Mail card")
template_screen_id = et["template_screen_id"]
closed_icon_id = _add_element(client, game, template_screen_id, "icone")
client.post(f"/game/{game}/elements/{closed_icon_id}/save", data={
"ctrl_visibilite_cond_mode": "objet", "ctrl_visibilite_cond_definition_id": str(def_id),
"ctrl_visibilite_cond_champ": "is_opened", "ctrl_visibilite_cond_operateur": "egal", "ctrl_visibilite_cond_valeur": "0",
})
open_icon_id = _add_element(client, game, template_screen_id, "icone")
client.post(f"/game/{game}/elements/{open_icon_id}/save", data={
"ctrl_visibilite_cond_mode": "objet", "ctrl_visibilite_cond_definition_id": str(def_id),
"ctrl_visibilite_cond_champ": "is_opened", "ctrl_visibilite_cond_operateur": "egal", "ctrl_visibilite_cond_valeur": "1",
})
screen_id = _create_screen(client, game, "Boîte de réception")
repeater_id = _add_element(client, game, screen_id, "repeteur_donnees")
client.post(f"/game/{game}/elements/{repeater_id}/save", data={
"ctrl_definition_id": str(def_id), "ctrl_template_type_id": str(et["id"]),
})
html = _rendered_html(client, game)
items = html.split('class="repeaterItem"')
assert len(items) == 3
row_closed, row_open = items[1], items[2]
assert row_closed.count('class="visibilityGated"') == 1
assert row_open.count('class="visibilityGated"') == 1
assert row_closed != row_open