From 1b7706b357f0c513190a3e4a387c3daf72117119 Mon Sep 17 00:00:00 2001 From: william Date: Sun, 30 Aug 2026 11:30:49 +0200 Subject: [PATCH 1/5] =?UTF-8?q?Ajoute=20les=20blocs=20de=20logique=20:=20o?= =?UTF-8?q?rganise=20le=20graphe=20de=20flow=20en=20sous-graphes=20nomm?= =?UTF-8?q?=C3=A9s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le graphe de logique d'une scène s'affichait jusqu'ici sur un seul canevas plat (toutes les scènes accumulant leurs nœuds sur la même grille), ce qui ne tient pas à l'échelle dès qu'une scène évolue au fil de l'avancée du joueur et accumule des centaines/milliers de nœuds. Ajoute les "Blocs de logique" : un bloc regroupe un sous-ensemble de nœuds/arêtes d'un écran sous un nom et une description (comme une fonction). L'onglet "Logique de la scène" devient une liste de blocs (nom, description tronquée à 3 phrases, éléments concernés, nombre de nœuds, bouton "Ouvrir"). Ouvrir un bloc affiche SON graphe dans une modale plein écran, redimensionnable et déplaçable (patron déjà mûr dans game_dashboard.html, porté tel quel : makeFloatPanelDraggable/ Resizable/Fullscreenable). Décision d'architecture : un bloc est un automate FERMÉ — impossible de relier un nœud d'un bloc à un nœud d'un autre bloc (rejeté côté serveur dans flow_edge_add.py). Toute communication entre deux blocs passe par le système d'événements personnalisés déjà en place (declencher_evenement / trigger_event="evenement"). Détails techniques : - Nouvelle colonne _flow_nodes.block_id (nullable, sans FK — même rationale que trigger_element_id/target_element_id, voir screens/elements/delete_element.py) et nouvelle table _flow_blocks (screens/flow/ensure_flow_schema.py, screens/flow/blocks/ensure_flow_blocks_schema.py). - Migration douce et automatique : les nœuds posés avant l'existence des blocs (block_id NULL) sont rattachés, à la première ouverture de l'onglet, à un "Bloc principal" auto-créé (screens/flow/blocks/ list_flow_blocks.py) — aucun script de migration séparé, aucune donnée perdue. - Suppression d'un bloc = cascade complète (bloc + tous ses nœuds/ arêtes), patron identique à screens/custom_events/delete_custom_event.py mais scopé à un seul bloc plutôt que game-wide. - Routes CRUD sous routes/flow_blocks/, montées comme routes/custom_events/. - templates/screen_edit.html : FLOW (global unique) renommé en ALL_FLOW (toutes les données de l'écran) ; un seul bloc ouvert à la fois (modale unique, à la Unity) — currentBlockNodes()/currentBlockEdges() filtrent ALL_FLOW par CURRENT_BLOCK_ID à chaque rendu, sans tenir de seconde copie à synchroniser manuellement. Vérifié : 215 tests passent (7 nouveaux dans tests/test_flow_blocks.py, dont un qui verrouille l'ordre d'appel list_flow_blocks()/ list_flow_nodes() dans screen_edit.py — la migration douce doit tourner AVANT le chargement des nœuds, sinon le compte de nœuds affiché juste après une migration est périmé), syntaxe JS validée (script de screen_edit.html rendu via le client de test puis node --check). Co-Authored-By: Claude Sonnet 5 --- core/jinja_filters.py | 2 + filters/truncate_sentences_filter.py | 14 ++ routes/__init__.py | 2 +- routes/flow/flow_edge_add.py | 9 + routes/flow_blocks/__init__.py | 1 + routes/flow_blocks/flow_block_delete.py | 14 ++ routes/flow_blocks/flow_block_edit.py | 13 + routes/flow_blocks/flow_block_new.py | 14 ++ routes/screens/screen_edit.py | 9 + screens/__init__.py | 7 + screens/flow/blocks/__init__.py | 0 screens/flow/blocks/blocks_view.py | 20 ++ screens/flow/blocks/create_flow_block.py | 19 ++ screens/flow/blocks/delete_flow_block.py | 26 ++ .../flow/blocks/ensure_flow_blocks_schema.py | 24 ++ screens/flow/blocks/get_flow_block.py | 13 + screens/flow/blocks/list_flow_blocks.py | 45 ++++ screens/flow/blocks/update_flow_block.py | 17 ++ screens/flow/constants.py | 5 + screens/flow/ensure_flow_schema.py | 13 + static/style.css | 8 + styles/forge-custom.css | 8 + templates/screen_edit.html | 233 ++++++++++++++++-- tests/test_flow_blocks.py | 144 +++++++++++ 24 files changed, 638 insertions(+), 22 deletions(-) create mode 100644 filters/truncate_sentences_filter.py create mode 100644 routes/flow_blocks/__init__.py create mode 100644 routes/flow_blocks/flow_block_delete.py create mode 100644 routes/flow_blocks/flow_block_edit.py create mode 100644 routes/flow_blocks/flow_block_new.py create mode 100644 screens/flow/blocks/__init__.py create mode 100644 screens/flow/blocks/blocks_view.py create mode 100644 screens/flow/blocks/create_flow_block.py create mode 100644 screens/flow/blocks/delete_flow_block.py create mode 100644 screens/flow/blocks/ensure_flow_blocks_schema.py create mode 100644 screens/flow/blocks/get_flow_block.py create mode 100644 screens/flow/blocks/list_flow_blocks.py create mode 100644 screens/flow/blocks/update_flow_block.py create mode 100644 tests/test_flow_blocks.py diff --git a/core/jinja_filters.py b/core/jinja_filters.py index 312293fe..184164a5 100644 --- a/core/jinja_filters.py +++ b/core/jinja_filters.py @@ -3,8 +3,10 @@ import screens from core.flask_app import app from filters.colname_filter import _colname from filters.element_style_filter import _element_style, _element_transform_style +from filters.truncate_sentences_filter import _truncate_sentences app.jinja_env.filters["colname"] = _colname app.jinja_env.filters["elstyle"] = _element_style app.jinja_env.filters["eltransform"] = _element_transform_style app.jinja_env.filters["elabel"] = screens.element_label +app.jinja_env.filters["truncatesentences"] = _truncate_sentences diff --git a/filters/truncate_sentences_filter.py b/filters/truncate_sentences_filter.py new file mode 100644 index 00000000..d0049581 --- /dev/null +++ b/filters/truncate_sentences_filter.py @@ -0,0 +1,14 @@ +import re + +_SENTENCE_END = re.compile(r"[.!?](?:\s+|$)") + + +def _truncate_sentences(text, n=3): + text = (text or "").strip() + if not text: + return "" + matches = list(_SENTENCE_END.finditer(text)) + if len(matches) <= n: + return text + cutoff = matches[n - 1].end() + return text[:cutoff].rstrip() + " …" diff --git a/routes/__init__.py b/routes/__init__.py index 66bd2092..2e9d945f 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -1 +1 @@ -from . import auth, games, objects, screens, elements, legacy_actions, flow, element_types, uploads, play, animations, global_vars, publish, custom_events +from . import auth, games, objects, screens, elements, legacy_actions, flow, element_types, uploads, play, animations, global_vars, publish, custom_events, flow_blocks diff --git a/routes/flow/flow_edge_add.py b/routes/flow/flow_edge_add.py index 0041965f..2aa43f8d 100644 --- a/routes/flow/flow_edge_add.py +++ b/routes/flow/flow_edge_add.py @@ -13,5 +13,14 @@ def flow_edge_add(slug, screen_id): to_node_id = int(payload["to_node_id"]) except (KeyError, TypeError, ValueError): return jsonify({"error": "nœuds invalides"}), 400 + from_node = screens.get_flow_node(slug, from_node_id) + to_node = screens.get_flow_node(slug, to_node_id) + if not from_node or not to_node: + return jsonify({"error": "nœuds invalides"}), 400 + if from_node.get("block_id") != to_node.get("block_id"): + # Un bloc de logique est un automate fermé (voir screens/flow/blocks/) : + # aucun câble entre deux blocs différents, toute communication passe + # par les événements personnalisés (declencher_evenement). + return jsonify({"error": "Impossible de relier deux nœuds de blocs différents"}), 400 edge_id = screens.add_flow_edge(slug, screen_id, from_node_id, payload.get("from_port", "out"), to_node_id) return jsonify({"id": edge_id}) diff --git a/routes/flow_blocks/__init__.py b/routes/flow_blocks/__init__.py new file mode 100644 index 00000000..1e9b3260 --- /dev/null +++ b/routes/flow_blocks/__init__.py @@ -0,0 +1 @@ +from . import flow_block_new, flow_block_edit, flow_block_delete diff --git a/routes/flow_blocks/flow_block_delete.py b/routes/flow_blocks/flow_block_delete.py new file mode 100644 index 00000000..cfb17256 --- /dev/null +++ b/routes/flow_blocks/flow_block_delete.py @@ -0,0 +1,14 @@ +from flask import redirect, url_for + +import screens + +from core.flask_app import app + + +@app.route("/game//screens//flow/blocks//delete", methods=["POST"]) +def flow_block_delete(slug, screen_id, block_id): + """Supprime le bloc ET tous ses nœuds/arêtes (voir + screens/flow/blocks/delete_flow_block.py) — confirmation côté client + avant l'envoi, affichant le nombre de nœuds concernés.""" + screens.delete_flow_block(slug, block_id) + return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id, tab="blocks")) diff --git a/routes/flow_blocks/flow_block_edit.py b/routes/flow_blocks/flow_block_edit.py new file mode 100644 index 00000000..01d32602 --- /dev/null +++ b/routes/flow_blocks/flow_block_edit.py @@ -0,0 +1,13 @@ +from flask import request, redirect, url_for + +import screens + +from core.flask_app import app + + +@app.route("/game//screens//flow/blocks//edit", methods=["POST"]) +def flow_block_edit(slug, screen_id, block_id): + name = (request.form.get("name") or "").strip() + description = request.form.get("description") or "" + screens.update_flow_block(slug, block_id, name, description) + return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id, tab="blocks")) diff --git a/routes/flow_blocks/flow_block_new.py b/routes/flow_blocks/flow_block_new.py new file mode 100644 index 00000000..e83179f4 --- /dev/null +++ b/routes/flow_blocks/flow_block_new.py @@ -0,0 +1,14 @@ +from flask import request, redirect, url_for + +import screens + +from core.flask_app import app + + +@app.route("/game//screens//flow/blocks/new", methods=["POST"]) +def flow_block_new(slug, screen_id): + name = (request.form.get("name") or "").strip() + description = request.form.get("description") or "" + if name: + screens.create_flow_block(slug, screen_id, name, description) + return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id, tab="blocks")) diff --git a/routes/screens/screen_edit.py b/routes/screens/screen_edit.py index 05b3e59b..1d666183 100644 --- a/routes/screens/screen_edit.py +++ b/routes/screens/screen_edit.py @@ -38,6 +38,11 @@ def screen_edit(slug, screen_id): definitions = db.list_definitions(slug) definitions_data = {str(d["id"]): screens.data_definition_options(slug, d["id"]) for d in definitions} + # list_flow_blocks() rattache paresseusement les nœuds "orphelins" + # (block_id NULL, jeux créés avant les blocs) à un "Bloc principal" — + # DOIT tourner avant list_flow_nodes() ci-dessous, sinon flow_nodes + # garderait en mémoire un block_id périmé (None) pour ces nœuds. + flow_blocks = screens.list_flow_blocks(slug, screen_id) flow_nodes = screens.list_flow_nodes(slug, screen_id) flow_edges = screens.list_flow_edges(slug, screen_id) elements_labels = {el["id"]: screens.element_label(el, screens.WIDGETS) for el in elements} @@ -48,6 +53,8 @@ def screen_edit(slug, screen_id): custom_event_usages = screens.list_custom_event_usages(slug) custom_events_map = {e["id"]: {"name": e["name"]} for e in custom_events} + blocks_view = screens.blocks_view(flow_blocks, flow_nodes, elements_labels) + return render_template( "screen_edit.html", game=game, screen=screen, elements=elements, top_level_elements=top_level_elements, @@ -85,4 +92,6 @@ def screen_edit(slug, screen_id): global_variables=global_variables, custom_events=custom_events, custom_event_usages=custom_event_usages, custom_events_map_json=json.dumps(custom_events_map), + blocks_view=blocks_view, + flow_blocks_json=json.dumps(flow_blocks), ) diff --git a/screens/__init__.py b/screens/__init__.py index 6cd09235..3fdcca30 100644 --- a/screens/__init__.py +++ b/screens/__init__.py @@ -108,6 +108,13 @@ from .flow.delete_flow_node import delete_flow_node from .flow.add_flow_edge import add_flow_edge from .flow.delete_flow_edge import delete_flow_edge +from .flow.blocks.list_flow_blocks import list_flow_blocks +from .flow.blocks.get_flow_block import get_flow_block +from .flow.blocks.create_flow_block import create_flow_block +from .flow.blocks.update_flow_block import update_flow_block +from .flow.blocks.delete_flow_block import delete_flow_block +from .flow.blocks.blocks_view import blocks_view + from .data_actions.apply_data_action import apply_data_action from .data_actions.apply_variable_action import apply_variable_action diff --git a/screens/flow/blocks/__init__.py b/screens/flow/blocks/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/screens/flow/blocks/blocks_view.py b/screens/flow/blocks/blocks_view.py new file mode 100644 index 00000000..8ec5b1a3 --- /dev/null +++ b/screens/flow/blocks/blocks_view.py @@ -0,0 +1,20 @@ +def blocks_view(flow_blocks, flow_nodes, elements_labels): + """Enrichit chaque bloc (nom/description bruts) avec son nombre de + nœuds et les éléments qu'il concerne (trigger_element_id des + déclencheurs + target_element_id des actions, dédoublonnés) — calculé + en Python à partir de ce que routes/screens/screen_edit.py a déjà + chargé, sans requête SQL supplémentaire.""" + nodes_by_block = {} + for n in flow_nodes: + nodes_by_block.setdefault(n.get("block_id"), []).append(n) + view = [] + for b in flow_blocks: + block_nodes = nodes_by_block.get(b["id"], []) + element_ids = {n["trigger_element_id"] for n in block_nodes if n.get("trigger_element_id")} + element_ids |= {n["target_element_id"] for n in block_nodes if n.get("target_element_id")} + view.append({ + **b, + "node_count": len(block_nodes), + "element_labels": [elements_labels[eid] for eid in element_ids if eid in elements_labels], + }) + return view diff --git a/screens/flow/blocks/create_flow_block.py b/screens/flow/blocks/create_flow_block.py new file mode 100644 index 00000000..115556b1 --- /dev/null +++ b/screens/flow/blocks/create_flow_block.py @@ -0,0 +1,19 @@ +import db + +from .ensure_flow_blocks_schema import ensure_flow_blocks_schema + + +def create_flow_block(slug, screen_id, name, description=""): + name = (name or "").strip() + if not name: + return None + ensure_flow_blocks_schema(slug) + conn = db.connect(slug) + conn.execute( + "INSERT INTO _flow_blocks (screen_id, name, description) VALUES (?, ?, ?)", + (screen_id, name, (description or "").strip()), + ) + new_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"] + conn.commit() + conn.close() + return new_id diff --git a/screens/flow/blocks/delete_flow_block.py b/screens/flow/blocks/delete_flow_block.py new file mode 100644 index 00000000..dc02f443 --- /dev/null +++ b/screens/flow/blocks/delete_flow_block.py @@ -0,0 +1,26 @@ +import db + +from ..ensure_flow_schema import ensure_flow_schema +from .ensure_flow_blocks_schema import ensure_flow_blocks_schema + + +def delete_flow_block(slug, block_id): + """Supprime le bloc ET tout ce qu'il contient (nœuds + arêtes) — décision + explicite de l'utilisateur, un bloc de logique se comporte comme une + fonction qu'on supprime avec son corps. Patron identique à + screens/custom_events/delete_custom_event.py, mais scopé à un seul bloc + (pas game-wide) : les AUTRES blocs du même écran ne sont pas touchés.""" + ensure_flow_schema(slug) + ensure_flow_blocks_schema(slug) + conn = db.connect(slug) + conn.execute( + """DELETE FROM _flow_edges WHERE from_node_id IN + (SELECT id FROM _flow_nodes WHERE block_id = ?) + OR to_node_id IN + (SELECT id FROM _flow_nodes WHERE block_id = ?)""", + (block_id, block_id), + ) + conn.execute("DELETE FROM _flow_nodes WHERE block_id = ?", (block_id,)) + conn.execute("DELETE FROM _flow_blocks WHERE id = ?", (block_id,)) + conn.commit() + conn.close() diff --git a/screens/flow/blocks/ensure_flow_blocks_schema.py b/screens/flow/blocks/ensure_flow_blocks_schema.py new file mode 100644 index 00000000..a61415af --- /dev/null +++ b/screens/flow/blocks/ensure_flow_blocks_schema.py @@ -0,0 +1,24 @@ +import db + + +def ensure_flow_blocks_schema(slug): + """Un bloc de logique regroupe les nœuds d'UN écran (scène ou modèle) + en sous-graphe nommé/décrit, pour rester lisible même quand un écran + accumule des milliers de nœuds — voir _flow_nodes.block_id + (screens/flow/ensure_flow_schema.py). Contrairement à un événement + personnalisé (game-wide, voir db/custom_events/), un bloc n'a de sens + que pour SON écran : "name" n'est donc PAS unique globalement.""" + conn = db.connect(slug) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS _flow_blocks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + screen_id INTEGER NOT NULL REFERENCES _screens(id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT, + created_at TEXT DEFAULT CURRENT_TIMESTAMP + ); + """ + ) + conn.commit() + conn.close() diff --git a/screens/flow/blocks/get_flow_block.py b/screens/flow/blocks/get_flow_block.py new file mode 100644 index 00000000..9093f4cb --- /dev/null +++ b/screens/flow/blocks/get_flow_block.py @@ -0,0 +1,13 @@ +import db + +from .ensure_flow_blocks_schema import ensure_flow_blocks_schema + + +def get_flow_block(slug, block_id): + if not block_id: + return None + ensure_flow_blocks_schema(slug) + conn = db.connect(slug) + row = conn.execute("SELECT * FROM _flow_blocks WHERE id = ?", (block_id,)).fetchone() + conn.close() + return dict(row) if row else None diff --git a/screens/flow/blocks/list_flow_blocks.py b/screens/flow/blocks/list_flow_blocks.py new file mode 100644 index 00000000..5317642e --- /dev/null +++ b/screens/flow/blocks/list_flow_blocks.py @@ -0,0 +1,45 @@ +import db + +from ..ensure_flow_schema import ensure_flow_schema +from .ensure_flow_blocks_schema import ensure_flow_blocks_schema + +DEFAULT_BLOCK_NAME = "Bloc principal" + + +def list_flow_blocks(slug, screen_id): + """Migration douce : un écran qui a des nœuds posés AVANT l'existence + des blocs (block_id NULL) se voit attribuer, à la première consultation + de l'onglet Blocs, un bloc "Bloc principal" auto-créé qui les regroupe — + aucun script de migration séparé à faire tourner, aucune donnée perdue. + Idempotent : si "Bloc principal" existe déjà pour cet écran, on le + réutilise plutôt que d'en recréer un second.""" + ensure_flow_schema(slug) + ensure_flow_blocks_schema(slug) + conn = db.connect(slug) + orphan_count = conn.execute( + "SELECT COUNT(*) AS n FROM _flow_nodes WHERE screen_id = ? AND block_id IS NULL", + (screen_id,), + ).fetchone()["n"] + if orphan_count: + default_block = conn.execute( + "SELECT id FROM _flow_blocks WHERE screen_id = ? AND name = ?", + (screen_id, DEFAULT_BLOCK_NAME), + ).fetchone() + if default_block: + default_block_id = default_block["id"] + else: + conn.execute( + "INSERT INTO _flow_blocks (screen_id, name, description) VALUES (?, ?, ?)", + (screen_id, DEFAULT_BLOCK_NAME, ""), + ) + default_block_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"] + conn.execute( + "UPDATE _flow_nodes SET block_id = ? WHERE screen_id = ? AND block_id IS NULL", + (default_block_id, screen_id), + ) + conn.commit() + rows = conn.execute( + "SELECT * FROM _flow_blocks WHERE screen_id = ? ORDER BY id", (screen_id,) + ).fetchall() + conn.close() + return [dict(r) for r in rows] diff --git a/screens/flow/blocks/update_flow_block.py b/screens/flow/blocks/update_flow_block.py new file mode 100644 index 00000000..c007f909 --- /dev/null +++ b/screens/flow/blocks/update_flow_block.py @@ -0,0 +1,17 @@ +import db + +from .ensure_flow_blocks_schema import ensure_flow_blocks_schema + + +def update_flow_block(slug, block_id, name, description=""): + name = (name or "").strip() + if not name: + return + ensure_flow_blocks_schema(slug) + conn = db.connect(slug) + conn.execute( + "UPDATE _flow_blocks SET name = ?, description = ? WHERE id = ?", + (name, (description or "").strip(), block_id), + ) + conn.commit() + conn.close() diff --git a/screens/flow/constants.py b/screens/flow/constants.py index 41b407b3..7f1bafed 100644 --- a/screens/flow/constants.py +++ b/screens/flow/constants.py @@ -86,4 +86,9 @@ FLOW_NODE_FIELDS = { # avec ses propres réglages habituels (target_element_id normal, pas # de mécanisme dynamique dédié). "trigger_custom_event_id", "target_custom_event_id", + # Blocs de logique (voir screens/flow/blocks/) : à quel bloc ce nœud + # appartient. Aucun `update_flow_node` n'existe (éditer un nœud le + # recrée puis supprime l'ancien) — le client doit donc renvoyer + # block_id à chaque (ré)création, pas juste le poser une fois. + "block_id", } diff --git a/screens/flow/ensure_flow_schema.py b/screens/flow/ensure_flow_schema.py index 4f59eea3..47e5a6f8 100644 --- a/screens/flow/ensure_flow_schema.py +++ b/screens/flow/ensure_flow_schema.py @@ -83,5 +83,18 @@ def ensure_flow_schema(slug): conn.execute("ALTER TABLE _flow_nodes ADD COLUMN trigger_custom_event_id INTEGER") if "target_custom_event_id" not in cols: conn.execute("ALTER TABLE _flow_nodes ADD COLUMN target_custom_event_id INTEGER") + if "block_id" not in cols: + # Blocs de logique (voir screens/flow/blocks/) : regroupe les nœuds + # d'un même écran en sous-graphes nommés/décrits pour rester lisible + # à l'échelle de milliers de nœuds. Pas de FK vers _flow_blocks — + # même rationale que trigger_element_id/target_element_id + # (screens/elements/delete_element.py) : la suppression d'un bloc + # nettoie déjà elle-même ces lignes (voir + # screens/flow/blocks/delete_flow_block.py), une contrainte ferait + # double emploi. NULL = nœud pas encore rattaché à un bloc (jeux + # créés avant cette fonctionnalité) — list_flow_blocks() les + # rattache paresseusement à un bloc "Bloc principal" généré à la + # volée, la première fois que l'onglet Blocs est ouvert. + conn.execute("ALTER TABLE _flow_nodes ADD COLUMN block_id INTEGER") conn.commit() conn.close() diff --git a/static/style.css b/static/style.css index bc2f2ee9..60bf9f27 100644 --- a/static/style.css +++ b/static/style.css @@ -666,6 +666,14 @@ code{ background:var(--panel2); border:1px solid var(--border); border-radius:4p .objectPanelTabPanel{ display:none; flex-direction:column; } .objectPanelTabPanel.active{ display:flex; } +/* Modale d'un bloc de logique (voir openLogicBlockPanel() dans + screen_edit.html) : le .floatPanelBody générique n'est pas un conteneur + flex par défaut — cette classe l'y transforme pour que .nodeForm/ + .flowCanvasWrap gardent le même comportement (barre d'outils fixe, + canevas qui prend tout l'espace restant) qu'avant leur déménagement + hors de l'ancien onglet "Logique de la scène". */ +.logicBlockPanelBody{ display:flex; flex-direction:column; height:100%; } + .flowToolbar{ display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin-bottom:10px; flex:0 0 auto; } .nodeForm{ border:1px solid var(--border); border-radius:8px; padding:12px; margin-bottom:12px; background:var(--panel2); max-width:420px; flex:0 0 auto; } diff --git a/styles/forge-custom.css b/styles/forge-custom.css index 16644ee9..dfb4729f 100644 --- a/styles/forge-custom.css +++ b/styles/forge-custom.css @@ -474,6 +474,14 @@ code{ background:var(--panel2); border:1px solid var(--border); border-radius:4p .objectPanelTabPanel{ display:none; flex-direction:column; } .objectPanelTabPanel.active{ display:flex; } +/* Modale d'un bloc de logique (voir openLogicBlockPanel() dans + screen_edit.html) : le .floatPanelBody générique n'est pas un conteneur + flex par défaut — cette classe l'y transforme pour que .nodeForm/ + .flowCanvasWrap gardent le même comportement (barre d'outils fixe, + canevas qui prend tout l'espace restant) qu'avant leur déménagement + hors de l'ancien onglet "Logique de la scène". */ +.logicBlockPanelBody{ display:flex; flex-direction:column; height:100%; } + .flowToolbar{ display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin-bottom:10px; flex:0 0 auto; } .nodeForm{ border:1px solid var(--border); border-radius:8px; padding:12px; margin-bottom:12px; background:var(--panel2); max-width:420px; flex:0 0 auto; } diff --git a/templates/screen_edit.html b/templates/screen_edit.html index 273e5e72..8407a9e3 100644 --- a/templates/screen_edit.html +++ b/templates/screen_edit.html @@ -170,7 +170,7 @@ {% set current_aspect = screen.preview_aspect or '9/16' %}
- +
@@ -190,7 +190,70 @@

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

-
+
+
+

Blocs de logique

+ +
+ + + {% if blocks_view %} + {% for b in blocks_view %} +
+ {% endfor %} +
+ + + + {% for b in blocks_view %} + + + + + + + + + + + {% endfor %} + +
NomDescriptionÉléments concernésNœuds
{{ b.name }}{{ b.description|truncatesentences(3) }}{% if b.element_labels %}{{ b.element_labels|join(', ') }}{% else %}—{% endif %}{{ b.node_count }} + + +
+ +
+
+
+ {% else %} +

Aucun bloc pour l'instant — crée le premier ci-dessus, puis ouvre-le pour poser des déclencheurs/conditions/actions.

+ {% endif %} +
+ + +
@@ -2161,10 +2226,26 @@ document.querySelectorAll('.uploadInput').forEach(function(input){ // ---------- Logique de la scène : éditeur de flow à nœuds ---------- var FLOW_SCREEN_ID = {{ screen.id }}; -var FLOW = { +// Blocs de logique (voir screens/flow/blocks/) : ALL_FLOW porte TOUS les +// nœuds/arêtes de cet écran, tous blocs confondus (chargement inchangé) ; +// un seul bloc est ouvert à la fois (modale unique, à la Unity) — pas +// besoin d'une seconde copie filtrée à tenir synchronisée, currentBlockNodes()/ +// currentBlockEdges() recalculent la vue du bloc ouvert à chaque rendu. +var ALL_FLOW = { nodes: {{ flow_nodes_json|safe }}, edges: {{ flow_edges_json|safe }}, }; +var FLOW_BLOCKS = {{ flow_blocks_json|safe }}; +var CURRENT_BLOCK_ID = null; + +function currentBlockNodes() { + return ALL_FLOW.nodes.filter(function(n) { return n.block_id === CURRENT_BLOCK_ID; }); +} +function currentBlockEdges() { + var ids = {}; + currentBlockNodes().forEach(function(n) { ids[n.id] = true; }); + return ALL_FLOW.edges.filter(function(e) { return ids[e.from_node_id] && ids[e.to_node_id]; }); +} var ELEMENTS_LABELS = {{ elements_labels_json|safe }}; var SCREENS_MAP = {{ screens_map_json|safe }}; var ACTION_TYPE_LABELS_MAP = {{ action_type_labels_json|safe }}; @@ -2256,7 +2337,7 @@ function renderFlow() { canvas.innerHTML = ''; svg.innerHTML = ''; - FLOW.nodes.forEach(function(node) { + currentBlockNodes().forEach(function(node) { var div = document.createElement('div'); div.className = 'flowNode flowNode-' + node.node_type; div.style.left = node.x + 'px'; @@ -2322,7 +2403,7 @@ function portPosition(nodeId, side, sub) { function drawFlowEdges() { var svg = document.getElementById('flowEdges'); svg.innerHTML = ''; - FLOW.edges.forEach(function(edge) { + currentBlockEdges().forEach(function(edge) { var from = portPosition(edge.from_node_id, 'out', edge.from_port); var to = portPosition(edge.to_node_id, 'in', null); if (!from || !to) return; @@ -2348,7 +2429,7 @@ function onPortClick(nodeId, side, port) { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ from_node_id: flowPendingPort.nodeId, from_port: flowPendingPort.port, to_node_id: nodeId }) }).then(function(r){ return r.json(); }).then(function(edge){ - FLOW.edges.push({ id: edge.id, screen_id: FLOW_SCREEN_ID, from_node_id: flowPendingPort.nodeId, from_port: flowPendingPort.port, to_node_id: nodeId }); + ALL_FLOW.edges.push({ id: edge.id, screen_id: FLOW_SCREEN_ID, from_node_id: flowPendingPort.nodeId, from_port: flowPendingPort.port, to_node_id: nodeId }); flowPendingPort = null; drawFlowEdges(); }); @@ -2356,15 +2437,15 @@ function onPortClick(nodeId, side, port) { function deleteFlowEdge(edgeId) { fetch(flowUrl('/flow/edges/' + edgeId + '/delete'), { method: 'POST' }).then(function(){ - FLOW.edges = FLOW.edges.filter(function(e){ return e.id !== edgeId; }); + ALL_FLOW.edges = ALL_FLOW.edges.filter(function(e){ return e.id !== edgeId; }); drawFlowEdges(); }); } function deleteFlowNode(nodeId) { fetch(flowUrl('/flow/nodes/' + nodeId + '/delete'), { method: 'POST' }).then(function(){ - FLOW.nodes = FLOW.nodes.filter(function(n){ return n.id !== nodeId; }); - FLOW.edges = FLOW.edges.filter(function(e){ return e.from_node_id !== nodeId && e.to_node_id !== nodeId; }); + ALL_FLOW.nodes = ALL_FLOW.nodes.filter(function(n){ return n.id !== nodeId; }); + ALL_FLOW.edges = ALL_FLOW.edges.filter(function(e){ return e.from_node_id !== nodeId && e.to_node_id !== nodeId; }); renderFlow(); }); } @@ -2372,7 +2453,7 @@ function deleteFlowNode(nodeId) { function onFlowNodeMouseDown(e, nodeId) { if (e.target.classList.contains('flowPort') || e.target.classList.contains('flowNodeDelete')) return; e.preventDefault(); - var node = FLOW.nodes.find(function(n){ return n.id === nodeId; }); + var node = ALL_FLOW.nodes.find(function(n){ return n.id === nodeId; }); var div = document.querySelector('.flowNode[data-node-id="' + nodeId + '"]'); var startX = e.clientX, startY = e.clientY; var startLeft = node.x, startTop = node.y; @@ -2659,7 +2740,7 @@ function toggleFlowElementToggleMode() { } function submitNodeForm() { - var payload = { node_type: currentNodeFormType, x: 20, y: 20 + FLOW.nodes.length * 10 }; + var payload = { node_type: currentNodeFormType, x: 20, y: 20 + currentBlockNodes().length * 10, block_id: CURRENT_BLOCK_ID }; if (currentNodeFormType === 'trigger') { payload.trigger_event = document.getElementById('nf-trigger-event').value; @@ -2759,17 +2840,12 @@ function submitNodeForm() { fetch(flowUrl('/screens/' + FLOW_SCREEN_ID + '/flow/nodes/add'), { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload) }).then(function(r){ return r.json(); }).then(function(node){ - FLOW.nodes.push(node); + ALL_FLOW.nodes.push(node); closeNodeForm(); renderFlow(); }); } -// Panneau de logique de scène absent sur un écran-modèle (élément de jeu) — -// voir le commentaire Jinja au-dessus de .builderTabs. -var HAS_FLOW_PANEL = !!document.getElementById('flowTabPanel'); -if (HAS_FLOW_PANEL) { renderFlow(); } - // ---------- Onglets du centre : Écran / Logique de la scène / Timeline // d'animation — un seul visible à la fois, chacun occupant tout l'espace // disponible (remplace l'ancien panneau du bas rétractable/redimensionnable @@ -2782,7 +2858,6 @@ function switchBuilderTab(tab) { document.querySelectorAll('.builderTabPanel').forEach(function (panel) { panel.classList.toggle('active', panel.dataset.tabPanel === tab); }); - if (tab === 'flow') requestAnimationFrame(drawFlowEdges); } function toggleDashCreate(tab) { @@ -2794,13 +2869,129 @@ function toggleDashCreate(tab) { } } +function toggleBlockEditRow(blockId) { + document.getElementById('blockEditRow' + blockId).classList.toggle('hidden'); +} + +// ---------- Panneaux flottants : glisser-déposer + redimensionnement + +// plein écran ---------- Générique, porté tel quel depuis game_dashboard.html +// (mêmes gestes partout dans l'outil plutôt qu'une variante par panneau). +function makeFloatPanelDraggable(panel, header) { + var dragging = false, offsetX = 0, offsetY = 0; + header.addEventListener('mousedown', function (e) { + if (e.target.closest('button')) return; + dragging = true; + if (panel.classList.contains('floatPanel--fullscreen')) panel._fsToggle.exit(); + var rect = panel.getBoundingClientRect(); + panel.style.left = rect.left + 'px'; + panel.style.top = rect.top + 'px'; + panel.style.right = 'auto'; + panel.style.bottom = 'auto'; + panel.style.transform = 'none'; + offsetX = e.clientX - rect.left; + offsetY = e.clientY - rect.top; + document.body.style.userSelect = 'none'; + }); + document.addEventListener('mousemove', function (e) { + if (!dragging) return; + panel.style.left = (e.clientX - offsetX) + 'px'; + panel.style.top = (e.clientY - offsetY) + 'px'; + }); + document.addEventListener('mouseup', function () { + dragging = false; + document.body.style.userSelect = ''; + }); +} +function makeFloatPanelResizable(panel, handle) { + var resizing = false, startX = 0, startY = 0, startW = 0, startH = 0; + handle.addEventListener('mousedown', function (e) { + resizing = true; + if (panel.classList.contains('floatPanel--fullscreen')) panel._fsToggle.exit(); + var rect = panel.getBoundingClientRect(); + startX = e.clientX; startY = e.clientY; + startW = rect.width; startH = rect.height; + panel.style.width = startW + 'px'; + panel.style.maxWidth = 'none'; + document.body.style.userSelect = 'none'; + e.preventDefault(); + }); + document.addEventListener('mousemove', function (e) { + if (!resizing) return; + panel.style.width = Math.max(320, startW + (e.clientX - startX)) + 'px'; + panel.style.height = Math.max(200, startH + (e.clientY - startY)) + 'px'; + }); + document.addEventListener('mouseup', function () { + resizing = false; + document.body.style.userSelect = ''; + }); +} +function makeFloatPanelFullscreenable(panel, toggleBtn) { + var saved = null; + function enter() { + saved = { + left: panel.style.left, top: panel.style.top, right: panel.style.right, + width: panel.style.width, height: panel.style.height, + maxWidth: panel.style.maxWidth, transform: panel.style.transform, + }; + panel.classList.add('floatPanel--fullscreen'); + panel.style.left = '12px'; panel.style.top = '12px'; panel.style.right = '12px'; panel.style.bottom = '12px'; + panel.style.width = 'auto'; panel.style.height = 'auto'; panel.style.maxWidth = 'none'; panel.style.transform = 'none'; + toggleBtn.title = 'Quitter le plein écran'; + } + function exit() { + panel.classList.remove('floatPanel--fullscreen'); + panel.style.bottom = ''; + if (saved) { + panel.style.left = saved.left; panel.style.top = saved.top; panel.style.right = saved.right; + panel.style.width = saved.width; panel.style.height = saved.height; + panel.style.maxWidth = saved.maxWidth; panel.style.transform = saved.transform; + } + toggleBtn.title = 'Plein écran'; + } + toggleBtn.addEventListener('click', function () { + if (panel.classList.contains('floatPanel--fullscreen')) exit(); else enter(); + }); + panel._fsToggle = { enter: enter, exit: exit }; + return panel._fsToggle; +} + +// ---------- Modale d'un bloc de logique (voir screens/flow/blocks/) : un +// seul bloc ouvert à la fois, réutilise le même #nodeForm/#flowCanvas pour +// n'importe quel bloc — voir currentBlockNodes()/currentBlockEdges(). ---------- +(function () { + var panel = document.getElementById('logicBlockPanel'); + makeFloatPanelDraggable(panel, document.getElementById('logicBlockPanelHeader')); + makeFloatPanelResizable(panel, document.getElementById('logicBlockPanelResizeHandle')); + makeFloatPanelFullscreenable(panel, panel.querySelector('.floatPanelFullscreenBtn')); + window.openLogicBlockPanel = function (blockId, blockName) { + CURRENT_BLOCK_ID = blockId; + closeNodeForm(); + document.getElementById('logicBlockPanelTitle').textContent = blockName || '?'; + panel.classList.remove('hidden'); + panel._fsToggle.enter(); + renderFlow(); + requestAnimationFrame(drawFlowEdges); + }; + window.closeLogicBlockPanel = function () { + panel.classList.add('hidden'); + CURRENT_BLOCK_ID = null; + }; +})(); + // Onglet initial : ?tab=... (utilisé par les routes de CRUD des -// événements, routes/custom_events/, pour revenir directement sur -// l'onglet "Événements" après création/modification/suppression plutôt -// que de laisser deviner où cliquer — même patron que game_dashboard.html). +// événements/blocs, pour revenir directement sur le bon onglet après +// création/modification/suppression plutôt que de laisser deviner où +// cliquer — même patron que game_dashboard.html). ?block= ouvre en +// plus directement le bloc concerné (même idiome que ?edit=&subtab= dans +// game_dashboard.html). (function () { var requestedTab = {{ (request.args.get('tab') or '')|tojson }}; if (requestedTab) switchBuilderTab(requestedTab); + var requestedBlock = {{ request.args.get('block', type=int)|tojson }}; + if (requestedBlock) { + var block = FLOW_BLOCKS.find(function (b) { return b.id === requestedBlock; }); + if (block) openLogicBlockPanel(block.id, block.name); + } })(); // ---------- Timeline d'animation : piste par élément, clips Animate.css ou diff --git a/tests/test_flow_blocks.py b/tests/test_flow_blocks.py new file mode 100644 index 00000000..e56a5069 --- /dev/null +++ b/tests/test_flow_blocks.py @@ -0,0 +1,144 @@ +"""Blocs de logique (screens/flow/blocks/) : regroupe les nœuds d'UN écran +en sous-graphes nommés/décrits pour rester lisible même à l'échelle de +milliers de nœuds (voir templates/screen_edit.html, onglet "🧩 Blocs de +logique"). Un bloc est un automate FERMÉ : aucune arête possible entre deux +nœuds de blocs différents (toute communication inter-blocs passe par les +événements personnalisés, voir tests/test_custom_events.py).""" +import json +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 _add_flow_node(client, slug, screen_id, **fields): + resp = client.post( + f"/game/{slug}/screens/{screen_id}/flow/nodes/add", + data=json.dumps(fields), + content_type="application/json", + ) + assert resp.status_code == 200 + return resp.get_json()["id"] + + +def _add_flow_edge(client, slug, screen_id, from_node_id, to_node_id, from_port="out"): + return client.post( + f"/game/{slug}/screens/{screen_id}/flow/edges/add", + data=json.dumps({"from_node_id": from_node_id, "from_port": from_port, "to_node_id": to_node_id}), + content_type="application/json", + ) + + +def test_create_and_list_flow_blocks(client, game): + screen_id = _create_screen(client, game) + resp = client.post(f"/game/{game}/screens/{screen_id}/flow/blocks/new", data={ + "name": "Ouvrir la porte", "description": "Ouvre la porte au clic.", + }) + assert resp.status_code == 302 + blocks = screens.list_flow_blocks(game, screen_id) + assert len(blocks) == 1 + assert blocks[0]["name"] == "Ouvrir la porte" + + +def test_orphan_nodes_are_migrated_into_a_default_block(client, game): + screen_id = _create_screen(client, game) + # Nœud posé "avant" l'existence des blocs (aucun block_id transmis) — + # doit être rattaché à un "Bloc principal" auto-créé au premier + # appel de list_flow_blocks, sans rien perdre. + node_id = _add_flow_node(client, game, screen_id, node_type="trigger", trigger_event="affichage") + + blocks = screens.list_flow_blocks(game, screen_id) + assert len(blocks) == 1 + assert blocks[0]["name"] == "Bloc principal" + node = screens.get_flow_node(game, node_id) + assert node["block_id"] == blocks[0]["id"] + + # Idempotent : un second appel ne crée pas un second "Bloc principal". + blocks_again = screens.list_flow_blocks(game, screen_id) + assert len(blocks_again) == 1 + + +def test_node_created_with_a_block_id_is_not_touched_by_migration(client, game): + screen_id = _create_screen(client, game) + block_id = screens.create_flow_block(game, screen_id, "Mon bloc") + node_id = _add_flow_node( + client, game, screen_id, + node_type="trigger", trigger_event="affichage", block_id=block_id, + ) + blocks = screens.list_flow_blocks(game, screen_id) + assert len(blocks) == 1 + assert blocks[0]["id"] == block_id + assert screens.get_flow_node(game, node_id)["block_id"] == block_id + + +def test_edge_between_two_different_blocks_is_rejected(client, game): + screen_id = _create_screen(client, game) + block_a = screens.create_flow_block(game, screen_id, "Bloc A") + block_b = screens.create_flow_block(game, screen_id, "Bloc B") + node_a = _add_flow_node(client, game, screen_id, node_type="trigger", trigger_event="affichage", block_id=block_a) + node_b = _add_flow_node(client, game, screen_id, node_type="action", action_type="attendre", data_value="1", block_id=block_b) + + resp = _add_flow_edge(client, game, screen_id, node_a, node_b) + assert resp.status_code == 400 + + # Une arête entre deux nœuds du MÊME bloc, elle, doit réussir. + node_a2 = _add_flow_node(client, game, screen_id, node_type="action", action_type="attendre", data_value="1", block_id=block_a) + resp_ok = _add_flow_edge(client, game, screen_id, node_a, node_a2) + assert resp_ok.status_code == 200 + + +def test_deleting_a_block_cascades_only_its_own_nodes_and_edges(client, game): + screen_id = _create_screen(client, game) + block_a = screens.create_flow_block(game, screen_id, "Bloc A") + block_b = screens.create_flow_block(game, screen_id, "Bloc B") + + node_a1 = _add_flow_node(client, game, screen_id, node_type="trigger", trigger_event="affichage", block_id=block_a) + node_a2 = _add_flow_node(client, game, screen_id, node_type="action", action_type="attendre", data_value="1", block_id=block_a) + edge_resp = _add_flow_edge(client, game, screen_id, node_a1, node_a2) + assert edge_resp.status_code == 200 + edge_id = edge_resp.get_json()["id"] + + node_b1 = _add_flow_node(client, game, screen_id, node_type="trigger", trigger_event="affichage", block_id=block_b) + + resp = client.post(f"/game/{game}/screens/{screen_id}/flow/blocks/{block_a}/delete", data={}, follow_redirects=False) + assert resp.status_code == 302 + + assert screens.get_flow_block(game, block_a) is None + assert screens.get_flow_node(game, node_a1) is None + assert screens.get_flow_node(game, node_a2) is None + assert screens.list_flow_edges(game, screen_id) == [] or all(e["id"] != edge_id for e in screens.list_flow_edges(game, screen_id)) + + # Le bloc B et son nœud, eux, ne sont pas touchés. + assert screens.get_flow_block(game, block_b) is not None + assert screens.get_flow_node(game, node_b1) is not None + + +def test_screen_edit_page_shows_correct_node_count_right_after_migration(client, game): + """routes/screens/screen_edit.py doit lancer list_flow_blocks() (qui + migre les nœuds orphelins vers "Bloc principal") AVANT list_flow_nodes() + — sinon la page affiche 0 nœud pour "Bloc principal" au tout premier + chargement suivant la migration (block_id périmé en mémoire).""" + screen_id = _create_screen(client, game) + _add_flow_node(client, game, screen_id, node_type="trigger", trigger_event="affichage") + resp = client.get(f"/game/{game}/screens/{screen_id}/edit") + assert resp.status_code == 200 + assert b"Bloc principal" in resp.data + # Le compte de nœuds (colonne "Nœuds") doit valoir 1, pas 0 — cherche + # la cellule 1 qui suit le nom du bloc dans le tableau. + html = resp.get_data(as_text=True) + row = html[html.index("Bloc principal"):] + assert re.search(r"1", row) + + +def test_screen_edit_page_renders_blocks_tab(client, game): + screen_id = _create_screen(client, game) + screens.create_flow_block(game, screen_id, "Ouvrir la porte", "Ouvre la porte. " * 5) + resp = client.get(f"/game/{game}/screens/{screen_id}/edit") + assert resp.status_code == 200 + assert b"Blocs de logique" in resp.data + assert b"Ouvrir la porte" in resp.data -- 2.54.0 From a445b72a6e5a2cf1479f7593cd8e5ca739b2f1b9 Mon Sep 17 00:00:00 2001 From: william Date: Sun, 30 Aug 2026 11:48:25 +0200 Subject: [PATCH 2/5] Corrige "Ouvrir"/"Modifier" inertes dans l'onglet Blocs de logique MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deux bugs distincts, signalés par capture d'écran (bouton "Ouvrir" sans effet, ligne d'édition affichée en permanence au lieu d'être cachée) : 1. onclick="openLogicBlockPanel({{ b.id }}, {{ b.name|tojson }})" cassait l'attribut HTML : tojson produit des guillemets DOUBLES (valides en JSON), qui terminaient prématurément l'attribut onclick="..." lui-même entre guillemets doubles — le gestionnaire de clic généré était donc tronqué et invalide, provoquant une erreur JS non interceptée qui arrêtait aussi tout le script restant dans la même balise + + + + + + + + + + + + + {% endblock %} diff --git a/tests/conftest.py b/tests/conftest.py index 310c3cdc..bb350fdc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -87,7 +87,19 @@ def game(client): shutil.rmtree(game_dir) -_PLAY_JS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "static", "js", "play") +_STATIC_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "static", "js") +_PLAY_JS_DIR = os.path.join(_STATIC_DIR, "play") +_SCREEN_EDIT_JS_DIR = os.path.join(_STATIC_DIR, "screen_edit") + + +def _js_bundle(directory): + parts = [] + for name in sorted(os.listdir(directory)): + path = os.path.join(directory, name) + if os.path.isfile(path) and name.endswith(".js"): + with open(path, encoding="utf-8") as f: + parts.append(f.read()) + return "\n".join(parts) def play_js_bundle(): @@ -98,13 +110,13 @@ def play_js_bundle(): telle chaîne, sans dépendre d'un navigateur — avant le découpage, ça revenait à chercher dans le HTML de /game//play (tout était inline) ; maintenant la logique vit dans ces fichiers statiques.""" - parts = [] - for name in sorted(os.listdir(_PLAY_JS_DIR)): - path = os.path.join(_PLAY_JS_DIR, name) - if os.path.isfile(path) and name.endswith(".js"): - with open(path, encoding="utf-8") as f: - parts.append(f.read()) - return "\n".join(parts) + return _js_bundle(_PLAY_JS_DIR) + + +def screen_edit_js_bundle(): + """Même principe que play_js_bundle(), pour l'éditeur (static/js/ + screen_edit/, extrait de templates/screen_edit.html).""" + return _js_bundle(_SCREEN_EDIT_JS_DIR) @pytest.fixture diff --git a/tests/test_condition_variable.py b/tests/test_condition_variable.py index 3705b1e4..3b2fff28 100644 --- a/tests/test_condition_variable.py +++ b/tests/test_condition_variable.py @@ -13,6 +13,8 @@ passent.""" import json import re +from conftest import screen_edit_js_bundle + def _create_screen(client, slug, name="Accueil"): resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False) @@ -88,7 +90,7 @@ def test_screen_edit_panel_exposes_variable_source_condition_ui(client, game): assert 'id="nf-cond-source"' in html assert 'id="nf-cond-variable-fields"' in html assert "onFlowConditionSourceChange" in html - assert "onFlowClauseSourceChange" in html + assert "onFlowClauseSourceChange" in screen_edit_js_bundle() def test_condition_builder_still_available_without_any_object_defined(client, game): diff --git a/tests/test_flow_clicked_row.py b/tests/test_flow_clicked_row.py index 6c40c56b..c31ea1b9 100644 --- a/tests/test_flow_clicked_row.py +++ b/tests/test_flow_clicked_row.py @@ -10,6 +10,8 @@ import re import db import screens +from conftest import screen_edit_js_bundle + def _create_screen(client, slug, name="Accueil"): resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False) @@ -105,6 +107,8 @@ def test_ordinary_fixed_row_target_is_unaffected(client, game): def test_clicked_row_option_appears_in_the_flow_node_editor(client, game): _create_email_object(client, game) screen_id = _create_screen(client, game) - html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode() - assert "FLOW_CLICKED_ROW_ID" in html - assert "Ligne cliquée" in html + resp = client.get(f"/game/{game}/screens/{screen_id}/edit") + assert resp.status_code == 200 + js = screen_edit_js_bundle() + assert "FLOW_CLICKED_ROW_ID" in js + assert "Ligne cliquée" in js -- 2.54.0 From bb84b7b377612c37be822e98146a6bdd9e17db53 Mon Sep 17 00:00:00 2001 From: william Date: Sun, 30 Aug 2026 15:00:23 +0200 Subject: [PATCH 5/5] =?UTF-8?q?Phase=200=20:=20factorise=20compute=5Fnew?= =?UTF-8?q?=5Fvalue=20+=20ajoute=20pytest/node=20test=20=C3=A0=20la=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Duplication éliminée avant que la Phase 2 (hasard/opérations mathématiques) n'en ajoute 6 de plus aux DEUX fichiers : la chaîne d'opérations quasi identique entre screens/data_actions/ apply_data_action.py (champ d'objet) et apply_variable_action.py (variable globale) est factorisée dans un nouveau compute_operation.py::compute_new_value(operation, current, raw_value, is_decimal), réutilisé par les deux. Nouveau tests/test_compute_operation.py verrouille le comportement des 7 opérations existantes (dont les cas limites : valeur invalide, type décimal vs entier, opération inconnue) avant d'en ajouter d'autres. 2. .gitea/workflows/deploy.yml déployait en prod à chaque push sur main sans jamais exécuter la suite de tests — rien ne bloquait techniquement un commit cassé. Nouveau job "test" (pytest + node:test sur la logique pure de static/js/play/, via des conteneurs officiels plutôt que des actions du marketplace, cohérent avec le choix déjà fait dans ce fichier) tourne sur CHAQUE push (main ET dev, utile pour ce dépôt qui travaille sur dev) ; "build-and-push"/"deploy" gagnent un "needs: test" et restent réservés à main (filtre sur gitea.ref) — un push sur dev ne redéploie jamais la prod, seulement les tests. Vérifié : 223 tests passent (8 nouveaux), YAML validé. Co-Authored-By: Claude Sonnet 5 --- .gitea/workflows/deploy.yml | 35 +++++++++++- screens/data_actions/apply_data_action.py | 28 ++-------- screens/data_actions/apply_variable_action.py | 28 ++-------- screens/data_actions/compute_operation.py | 35 ++++++++++++ tests/test_compute_operation.py | 54 +++++++++++++++++++ 5 files changed, 133 insertions(+), 47 deletions(-) create mode 100644 screens/data_actions/compute_operation.py create mode 100644 tests/test_compute_operation.py diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index a9adeaba..8eeb6b7b 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -2,7 +2,14 @@ name: Build and deploy on: push: - branches: [main] + branches: [main, dev] + +# Le job "test" tourne sur CHAQUE push (main et dev) : jusqu'ici aucune +# étape de CI n'exécutait la suite de tests, rien n'empêchait un commit +# cassé d'atteindre la production (voir l'audit qualité de la Phase 0 du +# plan). "build-and-push"/"deploy", eux, restent réservés à main (via le +# filtre "if" sur gitea.ref) — un push sur dev ne doit jamais redéployer +# la prod, seulement faire tourner les tests. # Secrets à configurer dans Gitea (Paramètres du dépôt > Actions > Secrets) : # REGISTRY_HOST adresse du registre d'images (ex: gitea.exemple.com) @@ -19,7 +26,32 @@ on: # github.com / du miroir gitea.com depuis le runner self-hosted. jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # Conteneurs officiels plutôt que actions/setup-python|setup-node du + # marketplace — même logique que le reste de ce fichier (éviter une + # dépendance à la disponibilité de github.com/du miroir gitea.com + # depuis le runner self-hosted), et le runner n'a pas besoin + # d'installer quoi que ce soit d'autre que Docker, déjà requis pour + # build-and-push. + - name: Tests Python (pytest) + run: | + docker run --rm -v "$PWD":/app -w /app python:3.13-slim sh -c " + pip install --quiet -r requirements-dev.txt && + python -m pytest tests/ -q + " + + - name: Tests JS (node:test — logique pure de static/js/play/, voir le plan de modularisation) + run: | + docker run --rm -v "$PWD":/app -w /app node:20-slim \ + node --test static/js/play/__tests__/*.test.js + build-and-push: + needs: test + if: gitea.ref == 'refs/heads/main' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -41,6 +73,7 @@ jobs: deploy: needs: build-and-push + if: gitea.ref == 'refs/heads/main' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/screens/data_actions/apply_data_action.py b/screens/data_actions/apply_data_action.py index 8f32ae67..0d37ecfb 100644 --- a/screens/data_actions/apply_data_action.py +++ b/screens/data_actions/apply_data_action.py @@ -1,5 +1,7 @@ import db +from .compute_operation import compute_new_value + def apply_data_action(slug, action): """Exécute au moment du clic (mode jouable) une action "modifier_donnee" : @@ -28,29 +30,9 @@ def apply_data_action(slug, action): col = col + "_id" current = row.get(col) - if operation == "definir_texte": - new_value = raw_value or "" - elif operation == "definir_nombre": - try: - new_value = float(raw_value) if field_def["type"] == "nombre_decimal" else int(float(raw_value)) - except (TypeError, ValueError): - new_value = 0 - elif operation == "definir_bool_vrai": - new_value = 1 - elif operation == "definir_bool_faux": - new_value = 0 - elif operation == "basculer_bool": - new_value = 0 if current else 1 - elif operation in ("incrementer", "decrementer"): - try: - amount = float(raw_value) if raw_value not in (None, "") else 0 - except (TypeError, ValueError): - amount = 0 - if operation == "decrementer": - amount = -amount - result = (current or 0) + amount - new_value = result if field_def["type"] == "nombre_decimal" else int(result) - else: + try: + new_value = compute_new_value(operation, current, raw_value, field_def["type"] == "nombre_decimal") + except ValueError: return False new_value = _clamp_to_field_bounds(new_value, field_def) diff --git a/screens/data_actions/apply_variable_action.py b/screens/data_actions/apply_variable_action.py index f3d37704..0342ddbd 100644 --- a/screens/data_actions/apply_variable_action.py +++ b/screens/data_actions/apply_variable_action.py @@ -1,5 +1,7 @@ import db +from .compute_operation import compute_new_value + def apply_variable_action(slug, action): """Exécute au moment du clic (mode jouable) une action @@ -25,29 +27,9 @@ def apply_variable_action(slug, action): elif var_type == "booleen": current = 1 if str(current).strip().lower() in ("1", "true", "vrai") else 0 - if operation == "definir_texte": - new_value = raw_value or "" - elif operation == "definir_nombre": - try: - new_value = float(raw_value) if var_type == "nombre_decimal" else int(float(raw_value)) - except (TypeError, ValueError): - new_value = 0 - elif operation == "definir_bool_vrai": - new_value = 1 - elif operation == "definir_bool_faux": - new_value = 0 - elif operation == "basculer_bool": - new_value = 0 if current else 1 - elif operation in ("incrementer", "decrementer"): - try: - amount = float(raw_value) if raw_value not in (None, "") else 0 - except (TypeError, ValueError): - amount = 0 - if operation == "decrementer": - amount = -amount - result = (current or 0) + amount - new_value = result if var_type == "nombre_decimal" else int(result) - else: + try: + new_value = compute_new_value(operation, current, raw_value, var_type == "nombre_decimal") + except ValueError: return False return db.update_global_variable_value(slug, var_name, new_value) diff --git a/screens/data_actions/compute_operation.py b/screens/data_actions/compute_operation.py new file mode 100644 index 00000000..6f1ca773 --- /dev/null +++ b/screens/data_actions/compute_operation.py @@ -0,0 +1,35 @@ +def compute_new_value(operation, current, raw_value, is_decimal): + """Calcule la nouvelle valeur d'un champ d'objet ou d'une variable + globale pour une opération de DATA_OPERATIONS (voir + screens/labels/data_operations.py) — logique partagée par + apply_data_action.py (champ d'objet) et apply_variable_action.py + (variable globale), qui ne diffèrent que par l'origine de `current` + et la façon dont le résultat est persisté. `is_decimal` : True pour + un champ/une variable de type "nombre_decimal" (résultat numérique + gardé en flottant), False sinon (résultat toujours entier). + + Lève ValueError si `operation` n'est pas reconnue — à l'appelant de + la traduire en son propre échec (ex. `return False`).""" + if operation == "definir_texte": + return raw_value or "" + if operation == "definir_nombre": + try: + return float(raw_value) if is_decimal else int(float(raw_value)) + except (TypeError, ValueError): + return 0 + if operation == "definir_bool_vrai": + return 1 + if operation == "definir_bool_faux": + return 0 + if operation == "basculer_bool": + return 0 if current else 1 + if operation in ("incrementer", "decrementer"): + try: + amount = float(raw_value) if raw_value not in (None, "") else 0 + except (TypeError, ValueError): + amount = 0 + if operation == "decrementer": + amount = -amount + result = (current or 0) + amount + return result if is_decimal else int(result) + raise ValueError(f"opération inconnue : {operation!r}") diff --git a/tests/test_compute_operation.py b/tests/test_compute_operation.py new file mode 100644 index 00000000..df60e2c6 --- /dev/null +++ b/tests/test_compute_operation.py @@ -0,0 +1,54 @@ +"""Tests unitaires de compute_new_value (screens/data_actions/compute_operation.py) +— logique partagée par apply_data_action.py (champ d'objet) et +apply_variable_action.py (variable globale), factorisée pour ne plus être +dupliquée dans les deux fichiers avant que la Phase 2 (hasard/opérations +mathématiques) n'y ajoute 6 nouvelles opérations. Verrouille le +comportement des 7 opérations existantes.""" +import pytest + +from screens.data_actions.compute_operation import compute_new_value + + +def test_definir_texte(): + assert compute_new_value("definir_texte", "ancien", "nouveau", False) == "nouveau" + assert compute_new_value("definir_texte", "ancien", "", False) == "" + assert compute_new_value("definir_texte", "ancien", None, False) == "" + + +def test_definir_nombre_entier_et_decimal(): + assert compute_new_value("definir_nombre", 0, "5", False) == 5 + assert isinstance(compute_new_value("definir_nombre", 0, "5", False), int) + assert compute_new_value("definir_nombre", 0, "5.7", True) == 5.7 + assert compute_new_value("definir_nombre", 0, "pas un nombre", False) == 0 + + +def test_definir_bool(): + assert compute_new_value("definir_bool_vrai", 0, None, False) == 1 + assert compute_new_value("definir_bool_faux", 1, None, False) == 0 + + +def test_basculer_bool(): + assert compute_new_value("basculer_bool", 0, None, False) == 1 + assert compute_new_value("basculer_bool", 1, None, False) == 0 + assert compute_new_value("basculer_bool", None, None, False) == 1 + + +def test_incrementer_decrementer_entier(): + assert compute_new_value("incrementer", 10, "5", False) == 15 + assert compute_new_value("decrementer", 10, "5", False) == 5 + assert compute_new_value("incrementer", None, "5", False) == 5 + + +def test_incrementer_decimal_garde_les_decimales(): + result = compute_new_value("incrementer", 10.0, "0.5", True) + assert result == 10.5 + assert isinstance(result, float) + + +def test_incrementer_valeur_invalide_ne_change_rien(): + assert compute_new_value("incrementer", 10, "pas un nombre", False) == 10 + + +def test_operation_inconnue_leve_value_error(): + with pytest.raises(ValueError): + compute_new_value("multiplier", 10, "2", False) -- 2.54.0