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/routes/element_types/element_type_delete.py b/routes/element_types/element_type_delete.py index 7b268ba3..1172c0bf 100644 --- a/routes/element_types/element_type_delete.py +++ b/routes/element_types/element_type_delete.py @@ -9,4 +9,4 @@ from core.flask_app import app def element_type_delete(slug, element_type_id): if screens.element_type_usage_count(slug, element_type_id) == 0: screens.delete_element_type(slug, element_type_id) - return redirect(url_for("element_types", slug=slug)) + return redirect(url_for("game_dashboard", slug=slug, tab="element_types")) diff --git a/routes/element_types/element_type_edit.py b/routes/element_types/element_type_edit.py index a1ce7b90..75660849 100644 --- a/routes/element_types/element_type_edit.py +++ b/routes/element_types/element_type_edit.py @@ -13,4 +13,4 @@ def element_type_edit(slug, element_type_id): name = request.form.get("name", "").strip() or None bound_definition_id = request.form.get("bound_definition_id") or None screens.update_element_type(slug, element_type_id, name=name, bound_definition_id=bound_definition_id) - return redirect(url_for("element_types", slug=slug)) + return redirect(url_for("game_dashboard", slug=slug, tab="element_types")) diff --git a/routes/element_types/element_types.py b/routes/element_types/element_types.py index f00a3c88..f4f9fe6d 100644 --- a/routes/element_types/element_types.py +++ b/routes/element_types/element_types.py @@ -1,6 +1,5 @@ -from flask import render_template, request, redirect, url_for +from flask import request, redirect, url_for -import db import screens from core.flask_app import app @@ -8,18 +7,16 @@ from core.flask_app import app @app.route("/game//element-types", methods=["GET", "POST"]) def element_types(slug): - game = db.game_meta(slug) + """La création/gestion d'un élément de jeu se fait désormais depuis + l'onglet "Éléments de jeu" du tableau de bord (voir game_dashboard.html) + plutôt qu'une page dédiée — cette route ne fait donc plus que traiter la + création (barre de création repliable de cet onglet). Un GET direct + redirige vers le tableau de bord.""" if request.method == "POST": name = request.form.get("name", "").strip() icon = request.form.get("icon", "").strip() or "🧩" bound_definition_id = request.form.get("bound_definition_id") or None if name: screens.create_element_type(slug, name, icon, bound_definition_id) - return redirect(url_for("element_types", slug=slug)) - types = screens.list_element_types(slug) - usage = {t["id"]: screens.element_type_usage_count(slug, t["id"]) for t in types} - definitions = db.list_definitions(slug) - return render_template( - "element_types.html", game=game, types=types, usage=usage, widgets=screens.WIDGETS, - definitions=definitions, - ) + return redirect(url_for("game_dashboard", slug=slug, tab="element_types")) + return redirect(url_for("game_dashboard", slug=slug, tab="element_types")) diff --git a/routes/games/game_dashboard.py b/routes/games/game_dashboard.py index f46eb42d..bbb71a0e 100644 --- a/routes/games/game_dashboard.py +++ b/routes/games/game_dashboard.py @@ -1,12 +1,63 @@ from flask import render_template import db +import screens from core.flask_app import app @app.route("/game/") def game_dashboard(slug): + """Vue d'ensemble du jeu : tout ce qui a été créé (écrans, objets, + éléments de jeu réutilisables, variables), en un coup d'œil, avec un + accès direct à chaque élément — la barre de navigation du jeu (voir + base.html) permet déjà de sauter directement vers chaque éditeur, cette + page sert donc de résumé/point d'entrée plutôt que de répéter les mêmes + liens.""" game = db.game_meta(slug) definitions = db.list_definitions(slug) - return render_template("game_dashboard.html", game=game, definitions=definitions) + # Détail complet (avec ses champs) de chaque objet, pour le panneau + # d'édition déplaçable (voir game_dashboard.html) ouvert par le crayon + # ✏️ de l'onglet Objets — un panneau par objet, pré-rendu ici et + # simplement montré/masqué en JS, plutôt qu'un aller-retour serveur à + # l'ouverture (voir openObjectEditPanel()). + definitions_full = {d["id"]: db.get_definition(slug, d["id"]) for d in definitions} + + # Pour l'onglet "Données" de ce même panneau (remplace data_list.html/ + # object_view.py, retirés) : les lignes déjà enregistrées de chaque + # objet, les libellés lisibles de ses champs relation (pour l'affichage + # du tableau) et les options possibles de ces mêmes champs relation + # (pour le formulaire "Ajouter une entrée"). + rows_by_definition = {} + relation_labels_by_definition = {} + relation_options_by_definition = {} + referenced_by_definition = {} + for d in definitions: + full = definitions_full[d["id"]] + rows_by_definition[d["id"]] = db.list_rows(slug, full) + relation_labels = {} + relation_options = {} + for f in full["fields"]: + if f["type"] == "relation": + options = db.relation_options(slug, f["relation_definition_id"]) + relation_labels[f["id"]] = {opt["id"]: opt["label"] for opt in options} + relation_options[f["id"]] = options + relation_labels_by_definition[d["id"]] = relation_labels + relation_options_by_definition[d["id"]] = relation_options + referenced_by_definition[d["id"]] = db.definitions_referencing(slug, d["id"]) + + screen_list = screens.list_screens(slug) + element_types = screens.list_element_types(slug) + element_type_usage = {t["id"]: screens.element_type_usage_count(slug, t["id"]) for t in element_types} + variables = db.list_global_variables(slug) + return render_template( + "game_dashboard.html", game=game, definitions=definitions, + definitions_full=definitions_full, + rows_by_definition=rows_by_definition, + relation_labels_by_definition=relation_labels_by_definition, + relation_options_by_definition=relation_options_by_definition, + referenced_by_definition=referenced_by_definition, + screen_list=screen_list, element_types=element_types, + element_type_usage=element_type_usage, variables=variables, + variable_types=db.GLOBAL_VARIABLE_TYPES, field_types=db.FIELD_TYPES, + ) diff --git a/routes/global_vars/__init__.py b/routes/global_vars/__init__.py index e076e9b1..0da1055e 100644 --- a/routes/global_vars/__init__.py +++ b/routes/global_vars/__init__.py @@ -1,4 +1,3 @@ from .create_global_var import create_global_var -from .game_variables import game_variables from .global_var_edit import global_var_edit from .global_var_delete import global_var_delete diff --git a/routes/global_vars/create_global_var.py b/routes/global_vars/create_global_var.py index 38e88827..4f50a41f 100644 --- a/routes/global_vars/create_global_var.py +++ b/routes/global_vars/create_global_var.py @@ -15,4 +15,4 @@ def create_global_var(slug): default_value = request.form.get("default_value") or "" if name: db.create_global_variable(slug, name, var_type, default_value) - return redirect(url_for("game_variables", slug=slug)) + return redirect(url_for("game_dashboard", slug=slug, tab="variables")) diff --git a/routes/global_vars/game_variables.py b/routes/global_vars/game_variables.py deleted file mode 100644 index 80ae9ff0..00000000 --- a/routes/global_vars/game_variables.py +++ /dev/null @@ -1,19 +0,0 @@ -from flask import render_template - -import db - -from core.flask_app import app - - -@app.route("/game//variables") -def game_variables(slug): - """Page de gestion des variables globales (voir db/global_vars/) : un - objet "jeu" implicite dont les propriétés/valeurs sont visibles partout - (condition de visibilité, action "Modifier une variable"), plutôt - qu'attachées à un objet de données précis.""" - game = db.game_meta(slug) - variables = db.list_global_variables(slug) - return render_template( - "game_variables.html", game=game, variables=variables, - variable_types=db.GLOBAL_VARIABLE_TYPES, - ) diff --git a/routes/global_vars/global_var_delete.py b/routes/global_vars/global_var_delete.py index 4aa8920d..9a89f507 100644 --- a/routes/global_vars/global_var_delete.py +++ b/routes/global_vars/global_var_delete.py @@ -8,4 +8,4 @@ from core.flask_app import app @app.route("/game//variables//delete", methods=["POST"]) def global_var_delete(slug, var_id): db.delete_global_variable_by_id(slug, var_id) - return redirect(url_for("game_variables", slug=slug)) + return redirect(url_for("game_dashboard", slug=slug, tab="variables")) diff --git a/routes/global_vars/global_var_edit.py b/routes/global_vars/global_var_edit.py index d09ef66f..edf1cd63 100644 --- a/routes/global_vars/global_var_edit.py +++ b/routes/global_vars/global_var_edit.py @@ -10,4 +10,4 @@ def global_var_edit(slug, var_id): var_type = request.form.get("type") or "texte" value = request.form.get("value") or "" db.update_global_variable(slug, var_id, var_type, value) - return redirect(url_for("game_variables", slug=slug)) + return redirect(url_for("game_dashboard", slug=slug, tab="variables")) diff --git a/routes/objects/data_delete.py b/routes/objects/data_delete.py index d0a60a78..e1cda644 100644 --- a/routes/objects/data_delete.py +++ b/routes/objects/data_delete.py @@ -13,8 +13,9 @@ def data_delete(slug, definition_id, row_id): referencing = db.rows_referencing(slug, definition_id, row_id) if referencing: # Suppression bloquée : d'autres lignes pointent vers celle-ci via - # une relation. On revient à la liste, qui explique pourquoi via un - # paramètre de requête simple plutôt qu'une session/flash message. - return redirect(url_for("object_view", slug=slug, definition_id=definition_id, blocked_row=row_id)) + # une relation. On revient au panneau de cet objet (onglet + # Données), qui explique pourquoi via un paramètre de requête + # simple plutôt qu'une session/flash message. + return redirect(url_for("game_dashboard", slug=slug, tab="objects", edit=definition_id, subtab="data", blocked_row=row_id)) db.delete_row(slug, definition, row_id) - return redirect(url_for("object_view", slug=slug, definition_id=definition_id)) + return redirect(url_for("game_dashboard", slug=slug, tab="objects", edit=definition_id, subtab="data")) diff --git a/routes/objects/data_form.py b/routes/objects/data_form.py index 5fbe0d37..49e68601 100644 --- a/routes/objects/data_form.py +++ b/routes/objects/data_form.py @@ -1,32 +1,24 @@ -from flask import render_template, request, redirect, url_for, abort +from flask import request, redirect, url_for, abort import db def _data_form(slug, definition_id, row): - game = db.game_meta(slug) + """Ajouter/modifier une entrée se fait désormais depuis l'onglet + "Données" du panneau déplaçable de son objet (voir game_dashboard.html, + ouvert par le crayon ✏️ de l'onglet Objets) plutôt qu'une page dédiée + (data_form.html, retirée) — cette fonction ne fait donc plus que + traiter la soumission de ce formulaire. Un GET direct redirige vers le + tableau de bord, en rouvrant le panneau de cet objet sur son onglet + Données.""" definition = db.get_definition(slug, definition_id) if not definition: abort(404) - # Pour chaque champ de type relation, on prépare les options du - - {% elif f.type == 'texte_long' %} - - - {% elif f.type == 'nombre_entier' %} - - - {% elif f.type == 'nombre_decimal' %} - - - {% elif f.type == 'booleen' %} - - - {% elif f.type == 'date' %} - - - {% elif f.type == 'relation' %} - {% set current_rel = row[col ~ '_id'] if row else none %} -
- -
- {% if not relation_options.get(f.id) %} -

Aucune entrée n'existe encore pour l'objet lié — crée-en une d'abord si tu veux pouvoir la sélectionner ici.

- {% endif %} - {% endif %} - - - {% endfor %} - - - - -{% endblock %} diff --git a/templates/data_list.html b/templates/data_list.html deleted file mode 100644 index 0f9855e1..00000000 --- a/templates/data_list.html +++ /dev/null @@ -1,128 +0,0 @@ -{% extends "base.html" %} -{% block title %}{{ definition.name }} — {{ game.name }}{% endblock %} -{% block content_class %}content-wide content-objectEdit{% endblock %} -{% block body_class %}objectEditBody{% endblock %} -{% block breadcrumb %} -
  • {{ game.name }}
  • -
  • {{ definition.name }}
  • -{% endblock %} -{% block content %} -

    {{ definition.name }} - ✏️ -

    -

    Table SQL : {{ definition.table_name }} — {{ definition.fields|length }} champ(s)

    - -{% if referenced_by %} -
    - ⚠️ D'autres objets ont une relation vers « {{ definition.name }} » : - {{ referenced_by|map(attribute='name')|join(', ') }}. - La suppression de cet objet est bloquée tant que ces relations existent. -
    -{% endif %} - -{% if blocked_row_id %} -
    - ⚠️ Impossible de supprimer l'entrée #{{ blocked_row_id }} : elle est référencée par - {% for ref in blocked_row_refs %}{{ ref.count }} entrée(s) de « {{ ref.definition_name }} » (champ « {{ ref.field_name }} »){% if not loop.last %}, {% endif %}{% endfor %}. - Modifie ou supprime d'abord ces entrées. -
    -{% endif %} - -
    -
    -

    Ajouter

    -
    -

    Une entrée, c'est une ligne de la table SQL « {{ definition.table_name }} ».

    - -
    -
    - -
    -

    Données enregistrées

    - {% if rows %} - {% macro row_field_display(f, r) %} - {%- if f.type == 'relation' -%} - {{ relation_labels.get(f.id, {}).get(r[(f.name|colname) ~ '_id'], '—') }} - {%- elif f.type == 'booleen' -%} - {{ 'Oui' if r[f.name|colname] else 'Non' }} - {%- else -%} - {{ r[f.name|colname] }} - {%- endif -%} - {% endmacro %} -
    - - - - - {% for f in definition.fields %}{% endfor %} - - - - - {% for r in rows %} - - - {% for f in definition.fields %} - - {% endfor %} - - - {% endfor %} - -
    #{{ f.name }}
    {{ r.id }}{{ row_field_display(f, r) }} -
    - - ✏️ -
    - -
    -
    -
    -
    - - {% for r in rows %} - - {% endfor %} - {% else %} -

    Aucune donnée enregistrée pour l'instant.

    - {% endif %} -
    -
    - - -{% endblock %} diff --git a/templates/element_types.html b/templates/element_types.html deleted file mode 100644 index 7e0c26bb..00000000 --- a/templates/element_types.html +++ /dev/null @@ -1,73 +0,0 @@ -{% extends "base.html" %} -{% block title %}Éléments de jeu — {{ game.name }}{% endblock %} -{% block content_class %}content-wide content-objectEdit{% endblock %} -{% block body_class %}objectEditBody{% endblock %} -{% block breadcrumb %} -
  • {{ game.name }}
  • -
  • Éléments de jeu
  • -{% endblock %} -{% block content %} -

    Éléments de jeu

    -

    Un élément de jeu est un conteneur réutilisable.

    - -
    -
    -

    Créer

    -
    -
    -
    - - -
    -
    - - -
    -
    -
    - - -
    -
    -
    -
    - -
    -

    Catalogue

    - {% if types %} -
    - - - - {% for t in types %} - - - - - - {% endfor %} - -
    NomUsage
    {{ t.icon }} {{ t.name }} - {{ usage.get(t.id, 0) }} instance(s) - {% if t.bound_definition_id %} - {% for d in definitions %}{% if d.id == t.bound_definition_id %} · « {{ d.name }} »{% endif %}{% endfor %} - {% endif %} - - ✏️ -
    - -
    -
    -
    - {% else %} -

    Aucun élément de jeu défini pour l'instant.

    - {% endif %} -
    -
    -{% endblock %} diff --git a/templates/game_dashboard.html b/templates/game_dashboard.html index 54fb5922..1fce0814 100644 --- a/templates/game_dashboard.html +++ b/templates/game_dashboard.html @@ -1,63 +1,1116 @@ {% extends "base.html" %} {% block title %}{{ game.name }} — Forge Engine{% endblock %} -{% block content_class %}content-wide content-objectEdit{% endblock %} -{% block body_class %}objectEditBody{% endblock %} -{% block breadcrumb %}
  • {{ game.name }}
  • {% endblock %} +{% block content_class %}content-wide{% endblock %} {% block content %} -

    {{ game.name }}

    -

    Base de données : projects/{{ game.slug }}/game.db

    -
    -
    -

    Créer

    - {% else %} -

    Pas encore d'élément de jeu personnalisé — crées-en un.

    +

    Pas encore d'élément de jeu personnalisé — crées-en un.

    {% endif %}
    @@ -160,20 +156,23 @@ + switchBuilderTab() plus bas). Présents aussi sur un écran-modèle + (élément de jeu réutilisable, ex. "mail content") : ses enfants + gardent désormais TOUJOURS le même id, à chaque exemplaire posé sur + n'importe quelle scène (rechargés en direct depuis ce modèle à + chaque affichage, voir _render_element_type_children côté serveur — + l'ancien mécanisme qui les copiait avec un nouvel id à chaque pose, + instantiate_template_tree, a été retiré). Un déclencheur/une + animation posé ici, sur un enfant du modèle, s'exécute donc bien + partout où ce modèle est utilisé (voir findTriggerNode/ + collectAnimationClips dans templates/play.html). -->
    {% set current_aspect = screen.preview_aspect or '9/16' %} - {% if not screen.is_template %}
    - {% endif %}
    @@ -190,7 +189,6 @@

    Glisse pour déplacer, tire le coin pour redimensionner, clique pour sélectionner.

    - {% if not screen.is_template %}
    @@ -222,7 +220,7 @@
    - {% endif %}
    + {% if not global_variables %} -

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

    +

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

    {% endif %}
    @@ -811,7 +812,7 @@
    {% if c.key == 'visibilite_cond_variable' %} -

    Pas la variable qu'il te faut ? Gérer les variables du jeu ↗

    +

    Pas la variable qu'il te faut ? Gérer les variables du jeu ↗

    {% endif %} {% endif %} {% endif %} @@ -1717,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) + '"]'); @@ -1742,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 })); } @@ -1779,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); @@ -1787,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/templates/screens_list.html b/templates/screens_list.html deleted file mode 100644 index ad429be1..00000000 --- a/templates/screens_list.html +++ /dev/null @@ -1,62 +0,0 @@ -{% extends "base.html" %} -{% block title %}Écrans — {{ game.name }}{% endblock %} -{% block content_class %}content-wide content-objectEdit{% endblock %} -{% block body_class %}objectEditBody{% endblock %} -{% block breadcrumb %} -
  • {{ game.name }}
  • -
  • Écrans
  • -{% endblock %} -{% block content %} -

    Écrans du jeu

    -{% if request.args.get('blocked_screen') %} -
    - ⚠️ Suppression impossible : au moins un autre écran a un bouton qui cible précisément celui-ci. Retire d'abord cette action. -
    -{% endif %} - -
    -
    -

    Créer

    -
    -
    - - -
    -
    -
    -
    - -
    -

    Enchaînement des écrans

    - {% if screen_list %} -
    - - - - {% for s in screen_list %} - - - - - - {% endfor %} - -
    #Nom
    {{ loop.index }} - {{ s.name }} - {% if loop.first %} ★ Départ{% endif %} - -
    -
    - ✏️ -
    - -
    -
    -
    - {% else %} -

    Aucun écran pour l'instant — crée le premier à gauche.

    - {% endif %} -
    -
    -{% endblock %} diff --git a/tests/test_screens_and_elements.py b/tests/test_screens_and_elements.py index e61e4816..42b463fd 100644 --- a/tests/test_screens_and_elements.py +++ b/tests/test_screens_and_elements.py @@ -100,15 +100,17 @@ def test_element_type_template_case_a_and_case_b(client, game, mail_object): follow_redirects=False, ) assert resp.status_code == 302 - html = client.get(f"/game/{slug}/element-types").data.decode() + html = client.get(f"/game/{slug}").data.decode() m = re.search(r"/screens/(\d+)/edit", html) assert m, "le lien vers l'écran-modèle doit apparaître dans le catalogue" template_screen_id = int(m.group(1)) # L'écran-modèle est un écran caché (is_template) : absent de la liste - # normale des écrans jouables. - screens_html = client.get(f"/game/{slug}/screens").data.decode() - assert "CarteMail" not in screens_html or "Modèle" not in screens_html + # normale des écrans jouables (onglet "Écrans du jeu" du tableau de + # bord) — son nom complet ("Modèle : CarteMail") ne doit donc apparaître + # nulle part sur cette page, contrairement à "CarteMail" seul (le nom de + # l'élément de jeu lui-même, affiché dans l'onglet "Éléments de jeu"). + assert "Modèle : CarteMail" not in html # Construit le contenu du modèle : un titre lié au champ "sujet". resp = client.post( @@ -266,7 +268,7 @@ def test_screen_editor_property_panels_closed_by_default(client, game): def test_delete_element_type_removes_hidden_template_screen(client, game): resp = client.post(f"/game/{game}/element-types", data={"name": "ToDelete", "icon": "🧩"}, follow_redirects=False) - html = client.get(f"/game/{game}/element-types").data.decode() + html = client.get(f"/game/{game}").data.decode() m = re.search(r"/screens/(\d+)/edit", html) template_screen_id = int(m.group(1)) element_type_id = int(re.search(r"element-types/(\d+)/delete", html).group(1)) 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"]