From ff01b3903c6125b9a5d89929dd63ee427ae87f31 Mon Sep 17 00:00:00 2001 From: william Date: Tue, 25 Aug 2026 12:17:40 +0200 Subject: [PATCH] =?UTF-8?q?Corrige=20la=20condition=20de=20visibilit=C3=A9?= =?UTF-8?q?=20(mode=20"objet")=20=C3=A0=20l'int=C3=A9rieur=20d'un=20R?= =?UTF-8?q?=C3=A9p=C3=A9teur?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug remonté : dans une "Mail card" (élément de jeu réutilisable, deux icônes - enveloppe fermée/ouverte - conditionnées sur le champ is_opened de l'objet Email) posée dans un Répéteur de données, rien ne s'affichait jamais correctement. Cause : is_element_visible() (mode "objet") allait toujours chercher en base la ligne la plus récente de l'objet ciblé (convention "1 seule ligne = état de partie", correcte pour une Jauge suivant un état de partie), sans jamais tenir compte de la ligne EN COURS DE RENDU dans un Répéteur - donc tous les exemplaires du même modèle de ligne évaluaient la MÊME ligne (la plus récente de tout l'objet Email) au lieu de chacun la sienne, et affichaient donc tous exactement le même résultat. Fix : is_element_visible() reçoit maintenant le ctx de rendu (les {{champ}} de la ligne en cours, déjà posés par render_repeater.py) et, si le champ réglé s'y trouve, utilise directement cette valeur plutôt que d'interroger la base - un exemplaire de Répéteur voit donc bien SA propre ligne. Hors Répéteur, le comportement (ligne la plus récente de l'objet) est inchangé. Ajoute tests/test_visibility_condition.py (mode variable, mode objet hors Répéteur, absence dans l'éditeur, et ce cas précis dans un Répéteur) - cette fonctionnalité n'avait jusqu'ici aucun test persistant, seulement des scripts ad-hoc jetés après vérification. Co-Authored-By: Claude Sonnet 5 --- screens/rendering/render_element_html.py | 2 +- screens/rendering/visibility_condition.py | 30 ++++-- tests/test_visibility_condition.py | 122 ++++++++++++++++++++++ 3 files changed, 146 insertions(+), 8 deletions(-) create mode 100644 tests/test_visibility_condition.py diff --git a/screens/rendering/render_element_html.py b/screens/rendering/render_element_html.py index 53976f33..a569f8c0 100644 --- a/screens/rendering/render_element_html.py +++ b/screens/rendering/render_element_html.py @@ -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'' def _mark(html_str): diff --git a/screens/rendering/visibility_condition.py b/screens/rendering/visibility_condition.py index f89922b8..966fe39d 100644 --- a/screens/rendering/visibility_condition.py +++ b/screens/rendering/visibility_condition.py @@ -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) diff --git a/tests/test_visibility_condition.py b/tests/test_visibility_condition.py new file mode 100644 index 00000000..9b4cc325 --- /dev/null +++ b/tests/test_visibility_condition.py @@ -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