diff --git a/db/constants.py b/db/constants.py index 184f754c..e9f4739e 100644 --- a/db/constants.py +++ b/db/constants.py @@ -18,10 +18,16 @@ FIELD_TYPES = { # Types disponibles pour une variable globale (voir db/global_vars/) — un # sous-ensemble de FIELD_TYPES : ni "relation" (une variable globale n'a pas # d'objet à pointer) ni "texte_long"/"date" (pas gérés par -# apply_variable_action.py, qui ne coerce que ces 4 types). +# apply_variable_action.py, qui ne coerce que ces 4 types). "objet"/ +# "tableau" sont, eux, propres aux variables (pas de colonne SQL réelle, +# contrairement à un champ d'objet) : la valeur est stockée telle quelle +# (colonne TEXT) sous forme de JSON, lue via un chemin ".champ"/"[index]" +# (voir _resolve_variable_path dans screens/rendering/filter_repeater_rows.py). GLOBAL_VARIABLE_TYPES = { "texte": FIELD_TYPES["texte"], "nombre_entier": FIELD_TYPES["nombre_entier"], "nombre_decimal": FIELD_TYPES["nombre_decimal"], "booleen": FIELD_TYPES["booleen"], + "objet": {"label": "Objet (JSON)"}, + "tableau": {"label": "Tableau (JSON)"}, } diff --git a/db/global_vars/coerce_structured_value.py b/db/global_vars/coerce_structured_value.py new file mode 100644 index 00000000..9ab1492c --- /dev/null +++ b/db/global_vars/coerce_structured_value.py @@ -0,0 +1,21 @@ +import json + + +def coerce_structured_value(var_type, value): + """Pour une variable "objet"/"tableau" (voir db/constants.py), la + valeur stockée doit rester du JSON analysable — sinon la prochaine + lecture (_resolve_variable_path, screens/rendering/ + filter_repeater_rows.py) échouerait silencieusement à chaque fois. + Un JSON invalide retombe donc sur un défaut sûr ("{}"/"[]") plutôt que + d'être stocké tel quel. Les autres types ne sont jamais touchés ici.""" + if var_type not in ("objet", "tableau"): + return value + text = (value or "").strip() + fallback = "{}" if var_type == "objet" else "[]" + if not text: + return fallback + try: + json.loads(text) + except (TypeError, ValueError): + return fallback + return text diff --git a/db/global_vars/create_global_variable.py b/db/global_vars/create_global_variable.py index 29501df1..e2a40c0c 100644 --- a/db/global_vars/create_global_variable.py +++ b/db/global_vars/create_global_variable.py @@ -1,5 +1,6 @@ from ..connection import connect from .ensure_global_vars_schema import ensure_global_vars_schema +from .coerce_structured_value import coerce_structured_value def create_global_variable(slug, name, var_type="texte", default_value=""): @@ -11,6 +12,7 @@ def create_global_variable(slug, name, var_type="texte", default_value=""): name = (name or "").strip() if not name: return None + default_value = coerce_structured_value(var_type, default_value) ensure_global_vars_schema(slug) conn = connect(slug) existing = conn.execute("SELECT id FROM _global_variables WHERE name = ?", (name,)).fetchone() diff --git a/db/global_vars/update_global_variable.py b/db/global_vars/update_global_variable.py index 98c26831..93e927c0 100644 --- a/db/global_vars/update_global_variable.py +++ b/db/global_vars/update_global_variable.py @@ -1,16 +1,18 @@ from ..connection import connect from .ensure_global_vars_schema import ensure_global_vars_schema +from .coerce_structured_value import coerce_structured_value def update_global_variable(slug, var_id, var_type, value): - """Modifie le type et la valeur d'une variable existante depuis la page - de gestion (voir game_variables.html) — le NOM reste volontairement - immuable après création : c'est par ce nom qu'une condition de - visibilité ou une action "Modifier une variable" la référence (voir - get_global_variable.py), la renommer casserait ces réglages en - silence.""" + """Modifie le type et la valeur d'une variable existante depuis le + tableau de bord (onglet Variables, game_dashboard.html) — le NOM reste + volontairement immuable après création : c'est par ce nom qu'une + condition de visibilité ou une action "Modifier une variable" la + référence (voir get_global_variable.py), la renommer casserait ces + réglages en silence.""" + value = coerce_structured_value(var_type, str(value)) ensure_global_vars_schema(slug) conn = connect(slug) - conn.execute("UPDATE _global_variables SET type = ?, value = ? WHERE id = ?", (var_type, str(value), var_id)) + conn.execute("UPDATE _global_variables SET type = ?, value = ? WHERE id = ?", (var_type, value, var_id)) conn.commit() conn.close() diff --git a/screens/rendering/filter_repeater_rows.py b/screens/rendering/filter_repeater_rows.py index 314ac28f..6aca7715 100644 --- a/screens/rendering/filter_repeater_rows.py +++ b/screens/rendering/filter_repeater_rows.py @@ -1,3 +1,4 @@ +import json import re import db @@ -12,8 +13,41 @@ _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*\}\}$") +# toujours un point ("Objet.champ"). Le 2e groupe capture un CHEMIN +# optionnel à l'intérieur de la variable ({{$perso.nom}}, {{$scores[0]}}, +# chaînable : {{$perso.arme.degats}}) — utile seulement pour une variable +# de type "objet"/"tableau" (voir _resolve_variable_path ci-dessous), sans +# effet sur une variable scalaire (chemin vide dans ce cas). +_VAR_REF_PATTERN = re.compile(r"^\{\{\s*\$([^.{}\[\]]+)((?:\.[^.{}\[\]]+|\[\d+\])*)\s*\}\}$") + +# Un segment de chemin est soit ".champ" (clé d'objet) soit "[index]" +# (élément de tableau, 0 = premier) — voir _VAR_REF_PATTERN ci-dessus et le +# contrôle "Chemin dans la variable" de la Condition de visibilité +# (visibility_condition_controls.py). +_PATH_SEGMENT_RE = re.compile(r"\.([^.\[\]]+)|\[(\d+)\]") + + +def _resolve_variable_path(raw_value, path): + """Navigue dans une valeur JSON (chaîne stockée en base, colonne TEXT) + selon un chemin ".champ"/"[index]" chaînable. Ne lève jamais : chemin + vide -> valeur brute inchangée (utile pour une variable scalaire, où le + chemin est toujours vide) ; JSON invalide, clé/index absent ou valeur + intermédiaire pas navigable -> None — même convention que le reste de + ce fichier (objet/variable introuvable -> ne matche rien plutôt que + planter).""" + if not path: + return raw_value + try: + current = json.loads(raw_value) if raw_value else None + except (TypeError, ValueError): + return None + for m in _PATH_SEGMENT_RE.finditer(path): + key, index = m.group(1), m.group(2) + try: + current = current[key] if key is not None else current[int(index)] + except (KeyError, IndexError, TypeError): + return None + return current def _field_column(definition, field_name): @@ -45,7 +79,9 @@ def _resolve_filter_value(slug, raw_value): 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 + if not variable: + return None # variable supprimée depuis : ne matche rien plutôt que planter + return _resolve_variable_path(variable.get("value"), var_match.group(2)) m = _REF_PATTERN.match(stripped) if not m: diff --git a/screens/rendering/visibility_condition.py b/screens/rendering/visibility_condition.py index 966fe39d..f5921a82 100644 --- a/screens/rendering/visibility_condition.py +++ b/screens/rendering/visibility_condition.py @@ -1,6 +1,6 @@ import db -from .filter_repeater_rows import _resolve_filter_value, _compare, _field_column +from .filter_repeater_rows import _resolve_filter_value, _resolve_variable_path, _compare, _field_column def is_element_visible(slug, attrs, ctx=None): @@ -39,7 +39,15 @@ def is_element_visible(slug, attrs, ctx=None): variable = db.get_global_variable(slug, var_name) if not variable: return True # variable supprimée depuis : ne bloque jamais l'affichage - return _compare(variable.get("value"), operator, expected, variable.get("type") or "texte") + value = variable.get("value") + # "Chemin dans la variable" (voir visibility_condition_controls.py) + # : nécessaire pour comparer un CHAMP d'une variable "objet" ou un + # ÉLÉMENT d'une variable "tableau", pas seulement la variable + # entière — même syntaxe ".champ"/"[index]" que {{$var.chemin}}. + path = attrs.get("_visibilite_cond_variable_chemin") + if path: + value = _resolve_variable_path(value, path) + return _compare(value, operator, expected, variable.get("type") or "texte") definition_id = attrs.get("_visibilite_cond_definition_id") field_name = attrs.get("_visibilite_cond_champ") diff --git a/screens/widgets/control_groups/visibility_condition_controls.py b/screens/widgets/control_groups/visibility_condition_controls.py index 46a2786a..65b173bb 100644 --- a/screens/widgets/control_groups/visibility_condition_controls.py +++ b/screens/widgets/control_groups/visibility_condition_controls.py @@ -17,6 +17,12 @@ VISIBILITY_CONDITION_CONTROLS = [ default="", group=_GROUP), c_select("visibilite_cond_variable", "Variable", "attr:_visibilite_cond_variable", [("", "(sélectionner une variable)")], default="", group=_GROUP), + # Utile seulement si la variable choisie ci-dessus est de type + # "objet"/"tableau" (db/constants.py) : sans chemin, c'est la variable + # ENTIÈRE (son JSON tel quel) qui est comparée — voir + # _resolve_variable_path dans filter_repeater_rows.py pour la syntaxe. + c_text("visibilite_cond_variable_chemin", "Chemin dans la variable (optionnel)", + "attr:_visibilite_cond_variable_chemin", placeholder="ex. .nom ou [0].valeur", default="", group=_GROUP), c_select("visibilite_cond_definition_id", "Objet", "attr:_visibilite_cond_definition_id", [("", "(sélectionner un objet)")], default="", group=_GROUP), c_select("visibilite_cond_champ", "Champ", "attr:_visibilite_cond_champ", diff --git a/templates/game_dashboard.html b/templates/game_dashboard.html index f53313da..c8b9df5d 100644 --- a/templates/game_dashboard.html +++ b/templates/game_dashboard.html @@ -151,7 +151,7 @@ {% endfor %} - + {% 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"]