From aaf99544467ec50718e43a552dd6c73e222c97fc Mon Sep 17 00:00:00 2001 From: william Date: Thu, 27 Aug 2026 14:28:23 +0200 Subject: [PATCH] Variables globales Objet/Tableau, lisibles via un chemin dans les conditions et filtres MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deux nouveaux types de variable globale (db/constants.py) : "objet" et "tableau" — valeur stockée en JSON (colonne TEXT existante), avec validation à la création/modification (db/global_vars/ coerce_structured_value.py) : un JSON invalide retombe sur un défaut sûr ("{}"/"[]") plutôt que de corrompre silencieusement la variable pour toutes ses lectures suivantes. apply_variable_action.py n'a besoin d'aucun changement — "definir_texte" écrit déjà n'importe quelle chaîne telle quelle. Nouvelle résolution de chemin, partagée (screens/rendering/ filter_repeater_rows.py::_resolve_variable_path) : navigue dans la valeur JSON d'une variable selon un chemin ".champ"/"[index]" chaînable (ex. ".arme.degats", "[0].valeur") — ne lève jamais, renvoie None si le JSON est invalide ou qu'un segment du chemin ne correspond à rien. Branchée à deux endroits, qui lisaient déjà une variable globale : - La valeur de comparaison {{$nom_variable}} (filtres de Répéteur ET Condition de visibilité, qui partagent le même _resolve_filter_value()) accepte maintenant un chemin optionnel : {{$perso.nom}}, {{$scores[0]}}. Le sélecteur "Variable globale" du panneau de propriétés (screen_edit.html, .filterValueVariable) gagne un champ "Chemin optionnel" à côté du choix de variable — même regex étendue côté JS (_VAR_REF_RE) que côté Python (_VAR_REF_PATTERN), pour que la valeur round-trip correctement à la réouverture du panneau. - La variable VÉRIFIÉE par une Condition de visibilité en mode "variable" (choisie via un {% if variables %} @@ -172,7 +172,13 @@ {% endfor %} - + + {% if v.type in ('objet', 'tableau') %} + + {% else %} + + {% endif %} +
@@ -711,6 +711,10 @@ {% endfor %} + + {% if not global_variables %}

Aucune variable globale pour l'instant — crées-en une ↗

{% endif %} @@ -1714,8 +1718,10 @@ 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*\\}\\}$'); +// ci-dessus, qui attend toujours un point ("Objet.champ"). Groupe 2 : un +// chemin optionnel ".champ"/"[index]" (chaînable) à l'intérieur de la +// variable, utile pour une variable "objet"/"tableau". +var _VAR_REF_RE = new RegExp('^\\{\\{\\s*\\$([^.{}\\[\\]]+)((?:\\.[^.{}\\[\\]]+|\\[\\d+\\])*)\\s*\\}\\}$'); function _filterValueDefinitionId(objName) { var opt = document.querySelector('.filterValueObjSel option[value="' + CSS.escape(objName) + '"]'); @@ -1739,8 +1745,10 @@ function updateFilterValueFromDynamic(wrap) { function updateFilterValueFromVariable(wrap) { var varSel = wrap.querySelector('.filterValueVarSel'); + var pathInput = wrap.querySelector('.filterValueVarPath'); var input = wrap.querySelector('.filterValueFixe input'); - input.value = varSel.value ? (_FILTER_REF_OPEN + '$' + varSel.value + _FILTER_REF_CLOSE) : ''; + var path = pathInput ? pathInput.value.trim() : ''; + input.value = varSel.value ? (_FILTER_REF_OPEN + '$' + varSel.value + path + _FILTER_REF_CLOSE) : ''; input.dispatchEvent(new Event('input', { bubbles: true })); } @@ -1776,6 +1784,7 @@ function initFilterValuePickers() { var objSel = wrap.querySelector('.filterValueObjSel'); var fieldSel = wrap.querySelector('.filterValueFieldSel'); var varSel = wrap.querySelector('.filterValueVarSel'); + var varPathInput = wrap.querySelector('.filterValueVarPath'); var current = (wrap.dataset.currentValue || '').trim(); var varMatch = _VAR_REF_RE.exec(current); var objMatch = _FILTER_REF_RE.exec(current); @@ -1784,6 +1793,7 @@ function initFilterValuePickers() { wrap.querySelector('.filterValueFixe').style.display = 'none'; wrap.querySelector('.filterValueVariable').style.display = 'block'; varSel.value = varMatch[1].trim(); + if (varPathInput) varPathInput.value = (varMatch[2] || '').trim(); } else if (objMatch) { modeSel.value = 'dynamique'; wrap.querySelector('.filterValueFixe').style.display = 'none'; diff --git a/tests/test_variable_object_array.py b/tests/test_variable_object_array.py new file mode 100644 index 00000000..0a134f4c --- /dev/null +++ b/tests/test_variable_object_array.py @@ -0,0 +1,121 @@ +"""Variables globales de type "objet"/"tableau" (db/constants.py) : la +valeur est du JSON, lisible via un chemin ".champ"/"[index]" chaînable +(_resolve_variable_path, screens/rendering/filter_repeater_rows.py) — +aussi bien dans un filtre de Répéteur ({{$var.chemin}}) que dans une +Condition de visibilité en mode "variable" (contrôle séparé +"visibilite_cond_variable_chemin").""" +import json +import re + +import db + + +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 _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": "12"}) + client.post(f"/game/{slug}/objects/{def_id}/data/new", data={"numero": "99"}) + return def_id + + +def test_create_and_update_validate_json_with_safe_fallback(client, game): + client.post(f"/game/{game}/variables/new", data={"name": "perso", "type": "objet", "default_value": "not json"}) + variable = db.get_global_variable(game, "perso") + assert variable["value"] == "{}" # JSON invalide -> défaut sûr, pas la chaîne brute + + client.post(f"/game/{game}/variables/new", data={"name": "scores", "type": "tableau", "default_value": ""}) + variable = db.get_global_variable(game, "scores") + assert variable["value"] == "[]" # vide -> défaut sûr propre au type + + db.update_global_variable(game, db.get_global_variable(game, "perso")["id"], "objet", json.dumps({"nom": "Alice"})) + assert json.loads(db.get_global_variable(game, "perso")["value"]) == {"nom": "Alice"} + + +def test_repeater_filter_reads_a_field_inside_an_object_variable(client, game): + client.post(f"/game/{game}/variables/new", data={ + "name": "perso", "type": "objet", + "default_value": json.dumps({"nom": "Alice", "arme": {"degats": 12}}), + }) + 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)) + + # {{$perso.arme.degats}} (chemin chaîné) doit résoudre à 12, et donc ne + # garder que la ligne "numero = 12". + 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": "{{$perso.arme.degats}}", + }) + html = _rendered_html(client, game) + assert html.count("repeaterItem") == 1 + assert 'data-row-id="1"' in html + assert 'data-row-id="2"' not in html + + +def test_visibility_condition_reads_an_array_element_by_index(client, game): + client.post(f"/game/{game}/variables/new", data={ + "name": "scores", "type": "tableau", "default_value": json.dumps([10, 20, 30]), + }) + screen_id = _create_screen(client, game) + element_id = _add_element(client, game, screen_id) + + # scores[1] == 20 -> visible. + client.post(f"/game/{game}/elements/{element_id}/save", data={ + "ctrl_visibilite_cond_mode": "variable", + "ctrl_visibilite_cond_variable": "scores", + "ctrl_visibilite_cond_variable_chemin": "[1]", + "ctrl_visibilite_cond_operateur": "egal", + "ctrl_visibilite_cond_valeur": "20", + }) + html = _rendered_html(client, game) + assert "display:none" not in html + + # scores[0] == 10, pas 20 -> masqué. + client.post(f"/game/{game}/elements/{element_id}/save", data={ + "ctrl_visibilite_cond_mode": "variable", + "ctrl_visibilite_cond_variable": "scores", + "ctrl_visibilite_cond_variable_chemin": "[0]", + "ctrl_visibilite_cond_operateur": "egal", + "ctrl_visibilite_cond_valeur": "20", + }) + html = _rendered_html(client, game) + assert "display:none" in html + + +def test_invalid_or_missing_path_never_crashes(client, game): + client.post(f"/game/{game}/variables/new", data={ + "name": "perso", "type": "objet", "default_value": json.dumps({"nom": "Alice"}), + }) + screen_id = _create_screen(client, game) + element_id = _add_element(client, game, screen_id) + client.post(f"/game/{game}/elements/{element_id}/save", data={ + "ctrl_visibilite_cond_mode": "variable", + "ctrl_visibilite_cond_variable": "perso", + "ctrl_visibilite_cond_variable_chemin": ".champ_inexistant", + "ctrl_visibilite_cond_operateur": "egal", + "ctrl_visibilite_cond_valeur": "peu importe", + }) + resp = client.get(f"/game/{game}/runtime-payload") + assert resp.status_code == 200 + assert "display:none" in resp.get_json()["screens"][0]["elements"][0]["rendered_html"]