From c9d3069f47695c0c291539788567dfa2978d022d Mon Sep 17 00:00:00 2001 From: william Date: Sun, 23 Aug 2026 18:02:02 +0200 Subject: [PATCH] Fix Jauge always reading the object's most recent row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Jauge could only target a whole object, not a specific record — three gauges pointing at the same "value" field (e.g. Réputation/ Trésorerie/Confiance in one "jauge" object) all silently showed the most recent row's value, with no way to tell them apart. Add "Enregistrement (ligne) à suivre" (row_id) so a Jauge targets one specific row, and "Champ contenant le nom" (champ_nom) to show a label above the bar — both as dropdowns populated from the object's actual fields/rows (previously "Champ numérique à afficher" was free text the user had to type correctly by hand). No regression: without row_id the widget still falls back to the latest row, exactly as before. Extract data_definition_options() (rows+fields for a definition) out of routes/screens/screen_edit.py so controls_with_values.py can reuse it server-side for the initial render; a small client-side handler (bindJaugeDefinitionSelect) repopulates the same selects live when the tracked object is changed without leaving the panel. Verified live with Playwright: picking "jauge" then "Confiance" then "value"/"name" in the panel renders an 80%-filled, green-leaning bar labelled "Confiance" on /play — not the 20%/50% of the other rows. --- routes/screens/screen_edit.py | 16 +---- screens/__init__.py | 1 + screens/rendering/render_jauge.py | 48 ++++++++++--- screens/widgets/controls_with_values.py | 20 +++++- screens/widgets/data_definition_options.py | 27 +++++++ screens/widgets/registry.py | 12 +++- templates/screen_edit.html | 28 +++++++- tests/test_jauge.py | 82 ++++++++++++++++++++++ 8 files changed, 204 insertions(+), 30 deletions(-) create mode 100644 screens/widgets/data_definition_options.py diff --git a/routes/screens/screen_edit.py b/routes/screens/screen_edit.py index 6e994def..468a179b 100644 --- a/routes/screens/screen_edit.py +++ b/routes/screens/screen_edit.py @@ -31,21 +31,7 @@ def screen_edit(slug, screen_id): elements_by_id = {el["id"]: el for el in elements} definitions = db.list_definitions(slug) - definitions_data = {} - for d in definitions: - full = db.get_definition(slug, d["id"]) - rows = db.list_rows(slug, full) - display_field = full["fields"][0]["name"] if full["fields"] else None - display_col = db.slugify(display_field).replace("-", "_") if display_field else None - row_options = [ - { - "id": r["id"], - "label": f"{r.get(display_col)} (#{r['id']})" if display_col else f"Ligne #{r['id']}", - } - for r in rows - ] - field_options = [{"name": f["name"], "type": f["type"]} for f in full["fields"] if f["type"] != "relation"] - definitions_data[str(d["id"])] = {"rows": row_options, "fields": field_options} + definitions_data = {str(d["id"]): screens.data_definition_options(slug, d["id"]) for d in definitions} flow_nodes = screens.list_flow_nodes(slug, screen_id) flow_edges = screens.list_flow_edges(slug, screen_id) diff --git a/screens/__init__.py b/screens/__init__.py index 5313fb71..44014358 100644 --- a/screens/__init__.py +++ b/screens/__init__.py @@ -43,6 +43,7 @@ from .widgets.layout_capable_widgets import LAYOUT_CAPABLE_WIDGETS from .widgets.font_options import GOOGLE_FONTS_LINK from .widgets.widget_meta import widget_meta from .widgets.controls_with_values import controls_with_values +from .widgets.data_definition_options import data_definition_options from .labels.trigger_labels import TRIGGER_LABELS from .labels.action_type_labels import ACTION_TYPE_LABELS diff --git a/screens/rendering/render_jauge.py b/screens/rendering/render_jauge.py index e9ea3c12..ea6fdbf1 100644 --- a/screens/rendering/render_jauge.py +++ b/screens/rendering/render_jauge.py @@ -22,12 +22,15 @@ def _interpolate_color(low_hex, high_hex, ratio): return "#{:02x}{:02x}{:02x}".format(*mixed) -def _read_latest_field_value(slug, definition_id, field_name): - """Lit la valeur du champ demandé sur la ligne la PLUS RÉCENTE de - l'objet visé — même convention que le filtre de Répéteur (1.2) : un - objet utilisé comme jauge de partie (Réputation, Trésorerie...) ne - garde qu'une seule ligne, mise à jour en place.""" - if not definition_id or not slug: +def _read_field_value(slug, definition_id, row_id, field_name): + """Lit la valeur du champ demandé sur l'enregistrement visé (row_id) de + l'objet suivi — quand un objet contient plusieurs enregistrements (ex: + Réputation/Trésorerie/Confiance dans un même objet "jauge"), row_id est + ce qui distingue lequel suivre. Repli sur la ligne la PLUS RÉCENTE si + row_id est absent ou introuvable (jauges créées avant ce réglage, ou + ligne supprimée depuis) — comportement historique, jamais de régression + pour un objet à une seule ligne.""" + if not definition_id or not field_name or not slug: return None definition = db.get_definition(slug, int(definition_id)) if not definition: @@ -35,8 +38,13 @@ def _read_latest_field_value(slug, definition_id, field_name): rows = db.list_rows(slug, definition) if not rows: return None + row = None + if row_id: + row = next((r for r in rows if str(r["id"]) == str(row_id)), None) + if row is None: + row = rows[0] col = db.slugify(field_name).replace("-", "_") - return rows[0].get(col) + return row.get(col) def _render_jauge(el, meta, slug, children_map=None, ctx=None, parent_flex_direction=None): @@ -48,9 +56,16 @@ def _render_jauge(el, meta, slug, children_map=None, ctx=None, parent_flex_direc "Modifier une donnée"), donc toujours à jour sans action supplémentaire à poser dans le graphe de logique.""" attrs = el.get("attributes") or {} - style = _style_string(el, " overflow:hidden; position:relative; background:#1c2130; border-radius:6px;", parent_flex_direction) + # display:flex column : le nom (optionnel) empile AU-DESSUS de la barre + # plutôt que de se superposer dedans — voir name_html plus bas. Le fond/ + # bordure/overflow vivent maintenant sur le wrapper de la barre + # (bar_wrap_style), pas ici, pour que le nom reste en dehors de la zone + # rognée par "overflow:hidden". + style = _style_string(el, " display:flex; flex-direction:column; gap:4px;", parent_flex_direction) definition_id = attrs.get("_definition_id") + row_id = attrs.get("_row_id") field_name = (attrs.get("_champ") or "").strip() + field_name_nom = (attrs.get("_champ_nom") or "").strip() try: min_v = float(attrs.get("_min", "0") or 0) except (TypeError, ValueError): @@ -63,7 +78,7 @@ def _render_jauge(el, meta, slug, children_map=None, ctx=None, parent_flex_direc couleur_haute = attrs.get("_couleur_haute") or "#4caf50" afficher_valeur = attrs.get("_afficher_valeur") == "1" - raw_value = _read_latest_field_value(slug, definition_id, field_name) if field_name else None + raw_value = _read_field_value(slug, definition_id, row_id, field_name) if field_name else None try: value = float(raw_value) except (TypeError, ValueError): @@ -82,6 +97,15 @@ def _render_jauge(el, meta, slug, children_map=None, ctx=None, parent_flex_direc f'color:#e8eaf0; text-shadow:0 1px 2px rgba(0,0,0,.6);">{html_lib.escape(str(raw_value if raw_value is not None else "—"))}' ) + name_html = "" + if field_name_nom: + nom_value = _read_field_value(slug, definition_id, row_id, field_name_nom) + if nom_value not in (None, ""): + name_html = ( + f'
{html_lib.escape(str(nom_value))}
' + ) + # La classe "jaugeBar" est repérée côté client (play.html, refreshRuntimeData) # pour savoir que ce wrapper doit être régénéré après toute action # "Modifier une donnée" — sinon la jauge resterait figée sur sa valeur @@ -91,7 +115,11 @@ def _render_jauge(el, meta, slug, children_map=None, ctx=None, parent_flex_direc f'
' ) + bar_wrap = ( + f'
{bar}{label_html}
' + ) return ( f'
' - f'{bar}{label_html}
' + f'{name_html}{bar_wrap}' ) diff --git a/screens/widgets/controls_with_values.py b/screens/widgets/controls_with_values.py index 7086d2c4..1cc3b500 100644 --- a/screens/widgets/controls_with_values.py +++ b/screens/widgets/controls_with_values.py @@ -2,15 +2,25 @@ import db from .widget_meta import widget_meta from .control_value import _control_value +from .data_definition_options import data_definition_options from ..element_types.list_element_types import list_element_types def controls_with_values(slug, el): """Pour le panneau de propriétés : la liste des réglages du widget de cet élément, chacun avec sa valeur actuelle déjà résolue. Le réglage - "définition_id" du Répéteur de données a besoin du jeu courant pour - lister les objets disponibles — d'où le paramètre slug.""" + "définition_id" du Répéteur de données (et de la Jauge) a besoin du jeu + courant pour lister les objets disponibles — d'où le paramètre slug.""" meta = widget_meta(el.get("widget")) + attributes = el.get("attributes") or {} + # Pour la Jauge : quel enregistrement suivre (row_id) et quels champs + # afficher (champ/champ_nom) ne peuvent être proposés qu'une fois un + # objet choisi (definition_id, déjà sur CET élément) — voir + # render_jauge.py. La mise à jour EN DIRECT quand on change d'objet + # sans quitter le panneau est gérée côté client (onJaugeDefinitionChange + # dans screen_edit.html), ceci ne couvre que le premier affichage/une + # navigation vers un élément déjà réglé. + jauge_options = data_definition_options(slug, attributes.get("_definition_id")) result = [] for control in meta.get("controls", []): control = dict(control) @@ -20,5 +30,11 @@ def controls_with_values(slug, el): if control["key"] == "template_type_id": types = list_element_types(slug) control["options"] = [("", "(aucun — utiliser le modèle de texte ci-dessous)")] + [(str(t["id"]), t["name"]) for t in types] + if control["key"] == "row_id": + control["options"] = [("", "(sélectionner un objet d'abord)")] + [(str(r["id"]), r["label"]) for r in jauge_options["rows"]] + if control["key"] == "champ": + control["options"] = [("", "(sélectionner un objet d'abord)")] + [(f["name"], f["name"]) for f in jauge_options["fields"]] + if control["key"] == "champ_nom": + control["options"] = [("", "— aucun —")] + [(f["name"], f["name"]) for f in jauge_options["fields"]] result.append({**control, "value": _control_value(el, control)}) return result diff --git a/screens/widgets/data_definition_options.py b/screens/widgets/data_definition_options.py new file mode 100644 index 00000000..f7fa341a --- /dev/null +++ b/screens/widgets/data_definition_options.py @@ -0,0 +1,27 @@ +import db + + +def data_definition_options(slug, definition_id): + """Lignes et champs d'un objet de données, prêts à peupler un menu + déroulant — utilisé à la fois pour DEFINITIONS_DATA (tous les objets du + jeu, consommé côté client par les formulaires de la Logique de la + scène) et pour pré-remplir côté serveur les réglages qui dépendent d'un + objet déjà choisi sur un élément (ex: la Jauge — voir + controls_with_values.py). Chaque ligne est étiquetée avec la valeur de + son PREMIER champ (convention "premier champ = nom lisible", comme pour + un Répéteur), pour distinguer "Confiance" de "Trésorerie" plutôt que de + n'avoir que des numéros de ligne.""" + if not definition_id or not slug: + return {"rows": [], "fields": []} + full = db.get_definition(slug, int(definition_id)) + if not full: + return {"rows": [], "fields": []} + rows = db.list_rows(slug, full) + display_field = full["fields"][0]["name"] if full["fields"] else None + display_col = db.slugify(display_field).replace("-", "_") if display_field else None + row_options = [ + {"id": r["id"], "label": f"{r.get(display_col)} (#{r['id']})" if display_col else f"Ligne #{r['id']}"} + for r in rows + ] + field_options = [{"name": f["name"], "type": f["type"]} for f in full["fields"] if f["type"] != "relation"] + return {"rows": row_options, "fields": field_options} diff --git a/screens/widgets/registry.py b/screens/widgets/registry.py index 31c632ee..99a68fc7 100644 --- a/screens/widgets/registry.py +++ b/screens/widgets/registry.py @@ -232,8 +232,16 @@ WIDGETS = { # objets de données du jeu — même mécanisme que le Répéteur. c_select("definition_id", "Objet de données à suivre", "attr:_definition_id", [("", "Sélectionner un objet")], default="", group="Donnée"), - c_text("champ", "Champ numérique à afficher", - "attr:_champ", placeholder="ex. reputation", default="", group="Donnée"), + # row_id/champ/champ_nom : options peuplées dynamiquement une + # fois "definition_id" choisi — voir controls_with_values.py + # (premier affichage) et onJaugeDefinitionChange() dans + # screen_edit.html (changement en direct dans le panneau). + c_select("row_id", "Enregistrement (ligne) à suivre", "attr:_row_id", + [("", "Sélectionner un objet d'abord")], default="", group="Donnée"), + c_select("champ", "Champ numérique à afficher", "attr:_champ", + [("", "Sélectionner un objet d'abord")], default="", group="Donnée"), + c_select("champ_nom", "Champ contenant le nom (affiché au-dessus, optionnel)", "attr:_champ_nom", + [("", "— aucun —")], default="", group="Donnée"), c_text("min", "Valeur mini", "attr:_min", default="0", group="Donnée"), c_text("max", "Valeur maxi", "attr:_max", default="100", group="Donnée"), c_color("couleur_basse", "Couleur côté mini", "attr:_couleur_basse", "#e05252", group="Apparence"), diff --git a/templates/screen_edit.html b/templates/screen_edit.html index 3b518adb..953404b1 100644 --- a/templates/screen_edit.html +++ b/templates/screen_edit.html @@ -281,7 +281,7 @@ {% endfor %} {% elif c.type in ('select', 'preset') %} - {% for opt in c.options %} {% endfor %} @@ -841,6 +841,32 @@ function initBuilderPanel() { }); bindPropsAutosave(); + bindJaugeDefinitionSelect(); +} + +// Jauge : quand on change l'objet de données à suivre SANS quitter le +// panneau (donc sans le rechargement serveur qui repeuplerait row_id/champ/ +// champ_nom via controls_with_values.py), on reconstruit ces trois menus à +// la volée à partir de DEFINITIONS_DATA (même source que les formulaires de +// la Logique de la scène, voir onFlowDefinitionChange). DEFINITIONS_DATA +// est déclaré plus bas dans ce script (donc encore "undefined" au moment où +// initBuilderPanel() tourne en tout premier) — sans conséquence ici, +// puisqu'on ne le lit que dans le gestionnaire, jamais au moment du bind. +function bindJaugeDefinitionSelect() { + var defSel = document.getElementById('field-definition_id'); + var rowSel = document.getElementById('field-row_id'); + var champSel = document.getElementById('field-champ'); + var champNomSel = document.getElementById('field-champ_nom'); + if (!defSel || !rowSel || !champSel || !champNomSel) return; // pas la Jauge + defSel.addEventListener('change', function () { + var info = (DEFINITIONS_DATA || {})[defSel.value] || { rows: [], fields: [] }; + rowSel.innerHTML = info.rows.length + ? info.rows.map(function (r) { return ''; }).join('') + : ''; + var fieldOptions = info.fields.map(function (f) { return ''; }).join(''); + champSel.innerHTML = fieldOptions || ''; + champNomSel.innerHTML = '' + fieldOptions; + }); } // Le retour/avance du navigateur (bouton "précédent") est déjà pris en diff --git a/tests/test_jauge.py b/tests/test_jauge.py index a3dbc05d..00a5507b 100644 --- a/tests/test_jauge.py +++ b/tests/test_jauge.py @@ -1,6 +1,8 @@ """Tests du widget Jauge (2.1 : binding live d'une donnée vers un style).""" 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) @@ -83,6 +85,86 @@ def test_gauge_value_clamped_visually_when_out_of_range(client, game): assert "width:100.0%" in html +# ---------- Choix de l'enregistrement (plusieurs jauges dans un même objet) +# et champ affiché comme nom au-dessus de la barre ---------- + +def _create_jauge_object_with_rows(client, slug): + """Un seul objet "jauge" avec trois enregistrements (name/value), comme + dans le scénario remonté : Réputation/Trésorerie/Confiance, toutes à 50 + — le bug était de ne pas pouvoir distinguer laquelle une Jauge suit.""" + resp = client.post(f"/game/{slug}/objects/new", data={ + "object_name": "jauge", + "field_name[]": ["name", "value"], "field_type[]": ["texte", "nombre_entier"], + "field_relation[]": ["", ""], "field_required[]": ["0", "0"], + }, follow_redirects=False) + def_id = int(resp.headers["Location"].rstrip("/").split("/")[-1]) + for name, value in [("Réputation", "50"), ("Trésorerie", "20"), ("Confiance", "80")]: + client.post(f"/game/{slug}/objects/{def_id}/data/new", data={"name": name, "value": value}, follow_redirects=False) + # La route de création redirige vers la liste (pas d'id de ligne dans le + # Location) — on relit les lignes enregistrées pour retrouver leurs id. + definition = db.get_definition(slug, def_id) + row_ids = {r["name"]: r["id"] for r in db.list_rows(slug, definition)} + return def_id, row_ids + + +def test_gauge_row_id_distinguishes_between_records_of_the_same_object(client, game): + """Le bug remonté : trois Jauges reliées au même objet (via "value") + affichaient toutes la même valeur (celle de la ligne la plus récente), + faute de pouvoir choisir LAQUELLE suivre. row_id doit permettre de + cibler "Confiance" (80%) plutôt que "Trésorerie" (20%).""" + def_id, row_ids = _create_jauge_object_with_rows(client, game) + screen_id = _create_screen(client, game) + resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "jauge"}, follow_redirects=False) + jauge_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1)) + client.post(f"/game/{game}/elements/{jauge_id}/save", data={ + "ctrl_definition_id": str(def_id), "ctrl_row_id": str(row_ids["Confiance"]), + "ctrl_champ": "value", "ctrl_min": "0", "ctrl_max": "100", + }) + html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode() + assert "width:80.0%" in html + + +def test_gauge_without_row_id_falls_back_to_latest_row_no_regression(client, game): + """Sans row_id réglé (jauges créées avant ce champ, ou objet à une seule + ligne comme dans test_jauge.py historique) : comportement inchangé, la + ligne la plus récente.""" + def_id, _row_ids = _create_jauge_object_with_rows(client, game) + screen_id = _create_screen(client, game) + resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "jauge"}, follow_redirects=False) + jauge_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1)) + client.post(f"/game/{game}/elements/{jauge_id}/save", data={ + "ctrl_definition_id": str(def_id), "ctrl_champ": "value", "ctrl_min": "0", "ctrl_max": "100", + }) + html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode() + assert "width:80.0%" in html # "Confiance", ajoutée en dernier + + +def test_gauge_name_field_renders_above_the_bar(client, game): + def_id, row_ids = _create_jauge_object_with_rows(client, game) + screen_id = _create_screen(client, game) + resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "jauge"}, follow_redirects=False) + jauge_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1)) + client.post(f"/game/{game}/elements/{jauge_id}/save", data={ + "ctrl_definition_id": str(def_id), "ctrl_row_id": str(row_ids["Confiance"]), + "ctrl_champ": "value", "ctrl_champ_nom": "name", "ctrl_min": "0", "ctrl_max": "100", + }) + html = client.get(f"/game/{game}/play").data.decode() + assert ">Confiance<" in html + + +def test_gauge_without_champ_nom_shows_no_name_no_regression(client, game): + def_id, row_ids = _create_jauge_object_with_rows(client, game) + screen_id = _create_screen(client, game) + resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "jauge"}, follow_redirects=False) + jauge_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1)) + client.post(f"/game/{game}/elements/{jauge_id}/save", data={ + "ctrl_definition_id": str(def_id), "ctrl_row_id": str(row_ids["Confiance"]), + "ctrl_champ": "value", "ctrl_min": "0", "ctrl_max": "100", + }) + html = client.get(f"/game/{game}/play").data.decode() + assert ">Confiance<" not in html + + # ---------- Régression : clics multipliés après un rafraîchissement de # données (bouton +10/-10 relié à une jauge) ----------