From 124f250d2bd82c4b4f77eedbc3ad1505fd8a2a67 Mon Sep 17 00:00:00 2001 From: william Date: Fri, 28 Aug 2026 09:47:12 +0200 Subject: [PATCH] =?UTF-8?q?Corrige=20le=20contenu=20de=20"Donn=C3=A9e=20li?= =?UTF-8?q?=C3=A9e"=20fig=C3=A9=20apr=C3=A8s=20un=20changement=20de=20vari?= =?UTF-8?q?able/donn=C3=A9e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug rapporté : changer une variable globale utilisée comme valeur de comparaison d'une "Donnée liée" (Texte/Titre, dans une boîte de dialogue posée comme élément de jeu réutilisable) recalculait bien, côté SERVEUR, la bonne ligne à afficher — mais le contenu affiché en jeu restait figé sur son ancienne ligne tant que la page n'était pas complètement rechargée. Cause : templates/play.html ne régénère, après une action "Modifier une variable"/"Modifier une donnée", QUE les éléments dont le rendered_html porte un marqueur connu (voir refreshRuntimeData()/hasMarker() — "repeaterItem", "jaugeBar", "visibilityGated"). Un Texte/Titre "Donnée liée" n'en portait AUCUN : jamais identifié comme "dépendant de la donnée", donc jamais régénéré, même si le nouveau HTML était déjà prêt côté serveur à chaque rendu. Correctif : nouveau marqueur "dataBound" (render_element_html.py), posé dès que _data_definition_id est réglé, reconnu par hasMarker(). Un exemplaire d'élément de jeu (ex. la boîte de dialogue posée sur une scène) porte ce marqueur EN PROFONDEUR dans son propre rendered_html dès qu'un de ses descendants internes en a un — il se retrouve donc bien régénéré dans son ensemble, sans changement supplémentaire nécessaire. Deux nouveaux tests, confirmés en échec sur l'ancien code (même scénario que le rapport : reproduit avec objet "dialog" + variable "dialog_order" + élément de jeu réutilisable) puis au vert avec le correctif. 155 tests au vert au total. Co-Authored-By: Claude Sonnet 5 --- screens/rendering/render_element_html.py | 21 ++++- templates/play.html | 12 ++- tests/test_data_binding_refresh_marker.py | 100 ++++++++++++++++++++++ 3 files changed, 129 insertions(+), 4 deletions(-) create mode 100644 tests/test_data_binding_refresh_marker.py diff --git a/screens/rendering/render_element_html.py b/screens/rendering/render_element_html.py index 1cf430e9..33780232 100644 --- a/screens/rendering/render_element_html.py +++ b/screens/rendering/render_element_html.py @@ -52,8 +52,27 @@ def render_element_html(el, slug=None, children_map=None, ctx=None, parent_flex_ if play_mode and has_visibility_condition and not is_element_visible(slug, attrs_raw, ctx): return f'' + # "Donnée liée" (DATA_BINDING_CONTROLS, Texte/Titre) : son contenu + # ({{champ}}) dépend de la ligne trouvée par ses conditions au moment du + # rendu — potentiellement via une "Variable globale" en valeur de + # comparaison (voir _resolve_filter_value). Comme "visibilityGated" + # juste au-dessus, un marqueur ("dataBound") reste posé sur le HTML pour + # que refreshRuntimeData() (play.html) sache qu'il faut réévaluer cet + # élément après toute action "Modifier une donnée"/"Modifier une + # variable" — sans lui, changer la variable choisie changeait bien QUELLE + # ligne aurait dû s'afficher (recalculé correctement côté serveur à + # chaque rendu), mais le client ne redemandait jamais le nouveau HTML de + # CET élément précis : le texte affiché restait figé sur son ancienne + # ligne jusqu'au prochain rechargement complet de la page. + has_data_binding = bool(attrs_raw.get("_data_definition_id")) + def _mark(html_str): - return html_str + "" if has_visibility_condition else html_str + markers = "" + if has_visibility_condition: + markers += "" + if has_data_binding: + markers += "" + return html_str + markers bound_ctx = _resolve_bound_row_ctx(slug, attrs_raw) if bound_ctx: diff --git a/templates/play.html b/templates/play.html index 82bf95f3..b82160f9 100644 --- a/templates/play.html +++ b/templates/play.html @@ -538,8 +538,9 @@ // le reste de la partie, sans le moindre message d'erreur visible. try { // "repeaterItem" (liste de données), "jaugeBar" (2.1 — jauge liée - // à une donnée) et "visibilityGated" (condition de visibilité — - // voir render_element_html.py) sont les types d'éléments dont le + // à une donnée), "visibilityGated" (condition de visibilité) et + // "dataBound" ("Donnée liée" d'un Texte/Titre — voir + // render_element_html.py) sont les types d'éléments dont le // contenu/l'affichage dépend de la donnée elle-même plutôt que // d'une action "Modifier un élément" posée à la main dans le // graphe — eux seuls doivent être régénérés après un changement @@ -568,7 +569,12 @@ const byId = {}; screenData.elements.forEach(function(e){ byId[e.id] = e; }); function hasMarker(e) { - return !!(e.rendered_html && (e.rendered_html.indexOf('repeaterItem') !== -1 || e.rendered_html.indexOf('jaugeBar') !== -1 || e.rendered_html.indexOf('visibilityGated') !== -1)); + return !!(e.rendered_html && ( + e.rendered_html.indexOf('repeaterItem') !== -1 || + e.rendered_html.indexOf('jaugeBar') !== -1 || + e.rendered_html.indexOf('visibilityGated') !== -1 || + e.rendered_html.indexOf('dataBound') !== -1 + )); } function isDescendantOf(e, ancestorId) { let p = e.parent_id ? byId[e.parent_id] : null; diff --git a/tests/test_data_binding_refresh_marker.py b/tests/test_data_binding_refresh_marker.py new file mode 100644 index 00000000..9352fe28 --- /dev/null +++ b/tests/test_data_binding_refresh_marker.py @@ -0,0 +1,100 @@ +"""Régression : changer une variable globale (ou une donnée) pendant la +partie recalculait bien, côté SERVEUR, quelle ligne "Donnée liée" (Texte/ +Titre) doit afficher — mais templates/play.html ne régénère, après une +telle action, que les éléments dont le rendered_html porte un marqueur +connu ("repeaterItem"/"jaugeBar"/"visibilityGated" — voir +refreshRuntimeData()/hasMarker()). Un Texte/Titre "Donnée liée" n'en +portait AUCUN : son contenu restait donc figé sur son ancienne ligne +jusqu'au prochain rechargement complet de page, même si le bon contenu +était déjà prêt côté serveur — "la variable change mais pas le contenu de +la modale". Corrigé en ajoutant un marqueur "dataBound" (render_element_html.py), +reconnu par hasMarker().""" +import re + +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 test_data_bound_element_carries_the_refresh_marker(client, game): + resp = client.post(f"/game/{game}/objects/new", data={ + "object_name": "Niveau", "field_name[]": ["numero", "spiker"], + "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/{game}/objects/{def_id}/data/new", data={"numero": "1", "spiker": "un"}) + client.post(f"/game/{game}/objects/{def_id}/data/new", data={"numero": "2", "spiker": "deux"}) + client.post(f"/game/{game}/variables/new", data={"name": "n", "type": "nombre_entier", "default_value": "1"}) + + 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": "{{spiker}}", + "ctrl_data_definition_id": str(def_id), + "ctrl_data_filtres_champ[]": ["numero"], + "ctrl_data_filtres_operateur[]": ["egal"], + "ctrl_data_filtres_valeur[]": ["{{$n}}"], + }) + + payload = client.get(f"/game/{game}/runtime-payload").get_json() + el = payload["screens"][0]["elements"][0] + assert "dataBound" in el["rendered_html"] + assert "un" in el["rendered_html"] + + # Change la variable -> le rendu recalculé (déjà correct côté serveur + # avant ce correctif) doit refléter la nouvelle ligne. + import db + variable = db.get_global_variable(game, "n") + db.update_global_variable(game, variable["id"], "nombre_entier", "2") + + payload2 = client.get(f"/game/{game}/runtime-payload").get_json() + el2 = payload2["screens"][0]["elements"][0] + assert "deux" in el2["rendered_html"] + assert "dataBound" in el2["rendered_html"] + + +def test_element_type_instance_bubbles_up_the_data_bound_marker(client, game): + """Même vérification, mais pour un Texte "Donnée liée" posé DANS un + élément de jeu réutilisable : le marqueur porté par l'élément interne + doit se retrouver dans le rendered_html de l'EXEMPLAIRE (le seul élément + réellement présent dans la liste plate de la scène qui l'accueille — + voir list_elements.py, les descendants d'un exemplaire sont exclus).""" + resp = client.post(f"/game/{game}/objects/new", data={ + "object_name": "dialog", "field_name[]": ["order", "spiker"], + "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/{game}/objects/{def_id}/data/new", data={"order": "1", "spiker": "Un"}) + client.post(f"/game/{game}/objects/{def_id}/data/new", data={"order": "2", "spiker": "Deux"}) + client.post(f"/game/{game}/variables/new", data={"name": "dialog_order", "type": "nombre_entier", "default_value": "1"}) + + client.post(f"/game/{game}/element-types", data={"name": "dialog"}, follow_redirects=False) + et = next(t for t in screens.list_element_types(game) if t["name"] == "dialog") + resp = client.post(f"/game/{game}/screens/{et['template_screen_id']}/elements/add", data={"widget": "superposition"}, follow_redirects=False) + overlay_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1)) + resp = client.post(f"/game/{game}/elements/{overlay_id}/children/add", data={"widget": "texte"}, follow_redirects=False) + spiker_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1)) + client.post(f"/game/{game}/elements/{spiker_id}/save", data={ + "ctrl_content": "{{spiker}}", + "ctrl_data_definition_id": str(def_id), + "ctrl_data_filtres_champ[]": ["order"], + "ctrl_data_filtres_operateur[]": ["egal"], + "ctrl_data_filtres_valeur[]": ["{{$dialog_order}}"], + }) + + screen_id = _create_screen(client, game, "Scene") + client.post(f"/game/{game}/screens/{screen_id}/elements/add", + data={"widget": "__catalogue__", "element_type_id": et["id"]}, follow_redirects=False) + + payload = client.get(f"/game/{game}/runtime-payload").get_json() + instance = payload["screens"][0]["elements"][0] + assert "dataBound" in instance["rendered_html"] + assert "Un" in instance["rendered_html"]