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