From 7d48445443876dd613a43bbbf19cbd2174132687 Mon Sep 17 00:00:00 2001 From: william Date: Tue, 25 Aug 2026 16:51:18 +0200 Subject: [PATCH] =?UTF-8?q?Ajoute=20"Variable=20globale"=20au=20s=C3=A9lec?= =?UTF-8?q?teur=20"Valeur=20fixe=20/=20Donn=C3=A9e=20d'un=20autre=20objet"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le sélecteur de valeur de comparaison (tout contrôle "..._valeur" : filtre du Répéteur, Donnée liée, condition de visibilité) proposait déjà une valeur fixe ou le champ d'un AUTRE objet - manquait la possibilité de comparer à une variable globale (voir db/global_vars/), qui change elle aussi en cours de partie mais n'est rattachée à aucun objet précis. Nouvelle syntaxe interne "{{$nom_variable}}" (le "$" la distingue sans ambiguïté de "{{Objet.champ}}", qui attend toujours un point) : _resolve_filter_value (filter_repeater_rows.py) va lire sa valeur actuelle via db.get_global_variable, comme "{{Objet.champ}}" le fait déjà pour un champ d'objet. Le panneau de propriétés gagne un troisième mode "Variable globale" à côté de "Valeur fixe"/"Donnée d'un autre objet", avec la liste déroulante des variables existantes. Corrige au passage un bug latent découvert en testant bout en bout : un Répéteur SANS modèle de ligne (texte brut avec {{champ}}) plantait en mode jouable avec TypeError - render_repeater.py substitue lui aussi directement les {{champ}} du ctx dans ce cas (repli), et ce ctx porte aussi _forge_play_mode (un booléen, voir render_element_html.py) depuis l'ajout de la condition de visibilité - déjà corrigé pour le chemin générique (apply_ctx.py) mais pas pour ce chemin séparé. Le sélecteur de champ pour insérer {{champ}} dans "Contenu" (demandé dans le même message) existe déjà depuis un tour précédent (voir insertFieldAtCursor()) - vérifié toujours fonctionnel. Ajoute tests/test_filter_value_global_variable.py. Co-Authored-By: Claude Sonnet 5 --- screens/rendering/filter_repeater_rows.py | 20 ++++- screens/rendering/render_repeater.py | 7 ++ templates/screen_edit.html | 59 +++++++++++--- tests/test_filter_value_global_variable.py | 95 ++++++++++++++++++++++ 4 files changed, 166 insertions(+), 15 deletions(-) create mode 100644 tests/test_filter_value_global_variable.py diff --git a/screens/rendering/filter_repeater_rows.py b/screens/rendering/filter_repeater_rows.py index a800acc2..314ac28f 100644 --- a/screens/rendering/filter_repeater_rows.py +++ b/screens/rendering/filter_repeater_rows.py @@ -9,6 +9,12 @@ import db # d'un Répéteur, voir _apply_ctx / applyOpenRowBindings côté JS). _REF_PATTERN = re.compile(r"^\{\{\s*([^.{}]+)\.([^.{}]+)\s*\}\}$") +# {{$nom_variable}} : référence à une VARIABLE GLOBALE (voir db/global_vars/) +# — le "$" (jamais utilisé dans un nom d'objet/de champ) distingue sans +# ambiguïté cette syntaxe de _REF_PATTERN ci-dessus, qui, elle, attend +# toujours un point ("Objet.champ"). +_VAR_REF_PATTERN = re.compile(r"^\{\{\s*\$([^.{}]+)\s*\}\}$") + def _field_column(definition, field_name): """Nom de la vraie colonne SQL d'un champ — un champ "relation" est @@ -29,11 +35,19 @@ def _resolve_filter_value(slug, raw_value): convention, un objet utilisé comme "état de partie" — niveau courant, outils débloqués... — ne garde qu'une seule ligne, mise à jour en place par des actions "Modifier une donnée" plutôt que d'en créer une - nouvelle à chaque fois). Sinon, renvoie raw_value tel quel (valeur - fixe).""" + nouvelle à chaque fois). Si c'est une référence "{{$nom_variable}}", va + lire la valeur ACTUELLE de cette variable globale (voir + db/global_vars/). Sinon, renvoie raw_value tel quel (valeur fixe).""" if not raw_value: return raw_value - m = _REF_PATTERN.match(raw_value.strip()) + stripped = raw_value.strip() + + var_match = _VAR_REF_PATTERN.match(stripped) + if var_match: + variable = db.get_global_variable(slug, var_match.group(1).strip()) + return variable.get("value") if variable else None # variable supprimée depuis : ne matche rien plutôt que planter + + m = _REF_PATTERN.match(stripped) if not m: return raw_value object_name, field_name = m.group(1).strip(), m.group(2).strip() diff --git a/screens/rendering/render_repeater.py b/screens/rendering/render_repeater.py index 7c295975..631cbb7e 100644 --- a/screens/rendering/render_repeater.py +++ b/screens/rendering/render_repeater.py @@ -59,6 +59,13 @@ def _render_repeater(el, meta, slug, children_map, ctx=None, parent_flex_directi else: line = template for fname, val in row_ctx.items(): + # "_forge_play_mode" (voir render_element_html.py) est un + # indicateur interne posé dans ce même ctx, pas une valeur + # de champ {{...}} — à ignorer ici (même garde que + # apply_ctx.py, qui gère le même ctx pour le chemin + # générique). + if fname == "_forge_play_mode": + continue line = line.replace("{{" + fname + "}}", val) inner = html_lib.escape(line) # data-row-id : identifiant STABLE de la vraie ligne de données diff --git a/templates/screen_edit.html b/templates/screen_edit.html index d44ae71a..433d4da5 100644 --- a/templates/screen_edit.html +++ b/templates/screen_edit.html @@ -684,6 +684,7 @@
@@ -704,6 +705,19 @@
+ {% elif c.type in ('text', 'url') %}
@@ -1700,6 +1714,11 @@ function bindDefinitionFieldSelects(defSelId, fieldSelIds) { var _FILTER_REF_OPEN = '{' + '{'; var _FILTER_REF_CLOSE = '}' + '}'; var _FILTER_REF_RE = new RegExp('^\\{\\{\\s*([^.{}]+)\\.([^.{}]+)\\s*\\}\\}$'); +// Une paire d'accolades doublées entourant "$nom_variable" (voir +// _VAR_REF_PATTERN côté Python, filter_repeater_rows.py) référence une +// variable globale — le "$" la distingue sans ambiguïté de _FILTER_REF_RE +// ci-dessus, qui attend toujours un point ("Objet.champ"). +var _VAR_REF_RE = new RegExp('^\\{\\{\\s*\\$([^.{}]+)\\s*\\}\\}$'); function _filterValueDefinitionId(objName) { var opt = document.querySelector('.filterValueObjSel option[value="' + CSS.escape(objName) + '"]'); @@ -1721,18 +1740,23 @@ function updateFilterValueFromDynamic(wrap) { input.dispatchEvent(new Event('input', { bubbles: true })); } +function updateFilterValueFromVariable(wrap) { + var varSel = wrap.querySelector('.filterValueVarSel'); + var input = wrap.querySelector('.filterValueFixe input'); + input.value = varSel.value ? (_FILTER_REF_OPEN + '$' + varSel.value + _FILTER_REF_CLOSE) : ''; + input.dispatchEvent(new Event('input', { bubbles: true })); +} + function onFilterValueModeChange(sel) { var wrap = sel.closest('.filterValueControl'); var fixe = wrap.querySelector('.filterValueFixe'); var dyn = wrap.querySelector('.filterValueDynamique'); - if (sel.value === 'dynamique') { - fixe.style.display = 'none'; - dyn.style.display = 'block'; - updateFilterValueFromDynamic(wrap); - } else { - fixe.style.display = 'block'; - dyn.style.display = 'none'; - } + var varWrap = wrap.querySelector('.filterValueVariable'); + fixe.style.display = sel.value === 'fixe' ? 'block' : 'none'; + dyn.style.display = sel.value === 'dynamique' ? 'block' : 'none'; + varWrap.style.display = sel.value === 'variable' ? 'block' : 'none'; + if (sel.value === 'dynamique') updateFilterValueFromDynamic(wrap); + else if (sel.value === 'variable') updateFilterValueFromVariable(wrap); } function onFilterValueObjChange(sel) { @@ -1745,19 +1769,30 @@ function onFilterValueFieldChange(sel) { updateFilterValueFromDynamic(sel.closest('.filterValueControl')); } +function onFilterValueVarChange(sel) { + updateFilterValueFromVariable(sel.closest('.filterValueControl')); +} + function initFilterValuePickers() { document.querySelectorAll('.filterValueControl').forEach(function (wrap) { var modeSel = wrap.querySelector('.filterValueMode'); var objSel = wrap.querySelector('.filterValueObjSel'); var fieldSel = wrap.querySelector('.filterValueFieldSel'); + var varSel = wrap.querySelector('.filterValueVarSel'); var current = (wrap.dataset.currentValue || '').trim(); - var m = _FILTER_REF_RE.exec(current); - if (m) { + var varMatch = _VAR_REF_RE.exec(current); + var objMatch = _FILTER_REF_RE.exec(current); + if (varMatch) { + modeSel.value = 'variable'; + wrap.querySelector('.filterValueFixe').style.display = 'none'; + wrap.querySelector('.filterValueVariable').style.display = 'block'; + varSel.value = varMatch[1].trim(); + } else if (objMatch) { modeSel.value = 'dynamique'; wrap.querySelector('.filterValueFixe').style.display = 'none'; wrap.querySelector('.filterValueDynamique').style.display = 'block'; - objSel.value = m[1].trim(); - populateFilterValueFields(fieldSel, _filterValueDefinitionId(objSel.value), m[2].trim()); + objSel.value = objMatch[1].trim(); + populateFilterValueFields(fieldSel, _filterValueDefinitionId(objSel.value), objMatch[2].trim()); } else { modeSel.value = 'fixe'; } diff --git a/tests/test_filter_value_global_variable.py b/tests/test_filter_value_global_variable.py new file mode 100644 index 00000000..da922f37 --- /dev/null +++ b/tests/test_filter_value_global_variable.py @@ -0,0 +1,95 @@ +"""Le sélecteur "Valeur fixe / Donnée d'un autre objet" (tout contrôle +"..._valeur", voir screen_edit.html) propose désormais aussi "Variable +globale" — syntaxe interne "{{$nom_variable}}" (voir _VAR_REF_PATTERN, +filter_repeater_rows.py), au même titre que "{{Objet.champ}}" pour un +objet.""" +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"], "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/{slug}/objects/{def_id}/data/new", data={"numero": "3"}) + client.post(f"/game/{slug}/objects/{def_id}/data/new", data={"numero": "5"}) + return def_id + + +def test_repeater_filter_resolves_a_global_variable_reference(client, game): + client.post(f"/game/{game}/variables/new", data={"name": "niveau_actuel", "type": "nombre_entier", "default_value": "3"}) + 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": "repeteur_donnees"}, follow_redirects=False) + rep_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1)) + client.post(f"/game/{game}/elements/{rep_id}/save", data={ + "ctrl_definition_id": str(def_id), + "ctrl_filtre_champ": "numero", + "ctrl_filtre_operateur": "egal", + "ctrl_filtre_valeur": "{{$niveau_actuel}}", + }) + + payload = client.get(f"/game/{game}/runtime-payload").get_json() + html = payload["screens"][0]["elements"][0]["rendered_html"] + assert html.count("repeaterItem") == 1 + + db.update_global_variable_value(game, "niveau_actuel", "5") + payload = client.get(f"/game/{game}/runtime-payload").get_json() + html = payload["screens"][0]["elements"][0]["rendered_html"] + assert html.count("repeaterItem") == 1 + + +def test_deleted_variable_reference_matches_nothing_rather_than_crashing(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": "repeteur_donnees"}, follow_redirects=False) + rep_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1)) + client.post(f"/game/{game}/elements/{rep_id}/save", data={ + "ctrl_definition_id": str(def_id), + "ctrl_filtre_champ": "numero", + "ctrl_filtre_operateur": "egal", + "ctrl_filtre_valeur": "{{$inexistante}}", + }) + payload = client.get(f"/game/{game}/runtime-payload").get_json() + html = payload["screens"][0]["elements"][0]["rendered_html"] + assert html.count("repeaterItem") == 0 + + +def test_variable_picker_appears_in_the_properties_panel(client, game): + client.post(f"/game/{game}/variables/new", data={"name": "niveau_actuel", "type": "nombre_entier", "default_value": "3"}) + 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": "repeteur_donnees"}, follow_redirects=False) + rep_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1)) + client.post(f"/game/{game}/elements/{rep_id}/save", data={"ctrl_definition_id": str(def_id)}) + + html = client.get(f"/game/{game}/screens/{screen_id}/edit?selected={rep_id}").data.decode() + assert "filterValueVarSel" in html + assert '' in html + + +def test_repeater_without_template_still_renders_in_play_mode(client, game): + """Non-régression : un Répéteur sans modèle de ligne (texte brut avec + {{champ}}) plantait en mode jouable avec TypeError: replace() argument + 2 must be str, not bool - le ctx porte aussi _forge_play_mode (voir + render_element_html.py), un booléen jamais destiné à une substitution + {{champ}}.""" + 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": "repeteur_donnees"}, follow_redirects=False) + rep_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1)) + client.post(f"/game/{game}/elements/{rep_id}/save", data={"ctrl_definition_id": str(def_id), "ctrl_content": "Numéro : {{numero}}"}) + + resp = client.get(f"/game/{game}/play") + assert resp.status_code == 200 + assert b"Num\xc3\xa9ro : 3" in resp.data