Dev #6
@@ -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
|
||||
|
||||
@@ -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() + " …"
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
from . import flow_block_new, flow_block_edit, flow_block_delete
|
||||
@@ -0,0 +1,14 @@
|
||||
from flask import redirect, url_for
|
||||
|
||||
import screens
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/screens/<int:screen_id>/flow/blocks/<int:block_id>/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"))
|
||||
@@ -0,0 +1,13 @@
|
||||
from flask import request, redirect, url_for
|
||||
|
||||
import screens
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/screens/<int:screen_id>/flow/blocks/<int:block_id>/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"))
|
||||
@@ -0,0 +1,14 @@
|
||||
from flask import request, redirect, url_for
|
||||
|
||||
import screens
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/screens/<int:screen_id>/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"))
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
+212
-21
@@ -170,7 +170,7 @@
|
||||
{% set current_aspect = screen.preview_aspect or '9/16' %}
|
||||
<div class="builderTabs" id="builderTabs">
|
||||
<button type="button" class="builderTabBtn active" data-tab="screen" onclick="switchBuilderTab('screen')">🖥️ Écran</button>
|
||||
<button type="button" class="builderTabBtn" data-tab="flow" onclick="switchBuilderTab('flow')">🔀 Logique de la scène</button>
|
||||
<button type="button" class="builderTabBtn" data-tab="blocks" onclick="switchBuilderTab('blocks')">🧩 Blocs de logique</button>
|
||||
<button type="button" class="builderTabBtn" data-tab="anim" onclick="switchBuilderTab('anim')">🎬 Timeline d'animation</button>
|
||||
<button type="button" class="builderTabBtn" data-tab="events" onclick="switchBuilderTab('events')">📣 Événements</button>
|
||||
</div>
|
||||
@@ -190,7 +190,70 @@
|
||||
<p class="hint" style="text-align:center;">Glisse pour déplacer, tire le coin pour redimensionner, clique pour sélectionner.</p>
|
||||
</div>
|
||||
|
||||
<div class="builderTabPanel" id="flowTabPanel" data-tab-panel="flow">
|
||||
<div class="builderTabPanel" id="blocksTabPanel" data-tab-panel="blocks">
|
||||
<div class="dashPanelHeader">
|
||||
<h2>Blocs de logique</h2>
|
||||
<button type="button" class="button is-small primary" onclick="toggleDashCreate('blocks')">+ Créer un bloc</button>
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('flow_block_new', slug=game.slug, screen_id=screen.id) }}" class="dashCreateBar" id="dashCreate-blocks" style="display:none;">
|
||||
<input type="text" name="name" placeholder="Nom du bloc (ex. Ouvrir la porte)" required autocomplete="off">
|
||||
<input type="text" name="description" placeholder="Description (optionnel)">
|
||||
<button type="submit" class="primary">Créer</button>
|
||||
</form>
|
||||
|
||||
{% if blocks_view %}
|
||||
{% for b in blocks_view %}
|
||||
<form method="post" action="{{ url_for('flow_block_edit', slug=game.slug, screen_id=screen.id, block_id=b.id) }}" id="blockEditForm{{ b.id }}"></form>
|
||||
{% endfor %}
|
||||
<div class="fieldsTableWrap">
|
||||
<table class="table is-fullwidth is-hoverable fieldsTable">
|
||||
<thead><tr><th>Nom</th><th>Description</th><th>Éléments concernés</th><th>Nœuds</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for b in blocks_view %}
|
||||
<tr>
|
||||
<td><strong>{{ b.name }}</strong></td>
|
||||
<td>{{ b.description|truncatesentences(3) }}</td>
|
||||
<td>{% if b.element_labels %}{{ b.element_labels|join(', ') }}{% else %}<span class="hint">—</span>{% endif %}</td>
|
||||
<td>{{ b.node_count }}</td>
|
||||
<td class="fieldsTableActions">
|
||||
<button type="button" class="button is-small is-primary" onclick="openLogicBlockPanel({{ b.id }}, {{ b.name|tojson }})" title="Ouvrir le graphe de ce bloc">🧩 Ouvrir</button>
|
||||
<button type="button" class="button is-small" onclick="toggleBlockEditRow({{ b.id }})" title="Modifier le nom/la description">✏️</button>
|
||||
<form method="post" action="{{ url_for('flow_block_delete', slug=game.slug, screen_id=screen.id, block_id=b.id) }}"
|
||||
onsubmit="return confirm('Supprimer le bloc « {{ b.name }} » ? Cela supprime aussi ses {{ b.node_count }} nœud(s) et leurs connexions.');" style="display:inline;">
|
||||
<button type="submit" class="button is-small is-danger" title="Supprimer">🗑️</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="hidden" id="blockEditRow{{ b.id }}">
|
||||
<td colspan="5">
|
||||
<div class="dashCreateBar" style="display:flex;">
|
||||
<input type="text" form="blockEditForm{{ b.id }}" name="name" value="{{ b.name }}" required>
|
||||
<input type="text" form="blockEditForm{{ b.id }}" name="description" value="{{ b.description or '' }}" placeholder="Description">
|
||||
<button type="submit" form="blockEditForm{{ b.id }}" class="primary">Enregistrer</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="hint">Aucun bloc pour l'instant — crée le premier ci-dessus, puis ouvre-le pour poser des déclencheurs/conditions/actions.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Modale plein écran/redimensionnable/déplaçable d'un bloc de logique
|
||||
(voir openLogicBlockPanel()) — patron makeFloatPanelDraggable/
|
||||
Resizable/Fullscreenable, identique à game_dashboard.html. Un seul
|
||||
bloc ouvert à la fois : ce même #nodeForm/#flowCanvas est réutilisé
|
||||
pour n'importe quel bloc (voir CURRENT_BLOCK_ID). -->
|
||||
<div class="floatPanel floatPanel--center hidden" id="logicBlockPanel">
|
||||
<div class="floatPanelHeader" id="logicBlockPanelHeader">
|
||||
<span class="floatPanelTitle">⋮⋮ <span id="logicBlockPanelTitle">Bloc</span></span>
|
||||
<button type="button" class="button is-small floatPanelFullscreenBtn" title="Plein écran">⛶</button>
|
||||
<button type="button" class="button is-small" onclick="closeLogicBlockPanel()">✕</button>
|
||||
</div>
|
||||
<div class="floatPanelBody logicBlockPanelBody">
|
||||
<div class="flowToolbar">
|
||||
<button type="button" class="smallBtn button is-small" onclick="openNodeForm('trigger')">➕ Déclencheur</button>
|
||||
<button type="button" class="smallBtn button is-small" onclick="openNodeForm('condition')">➕ Condition</button>
|
||||
@@ -408,6 +471,8 @@
|
||||
<svg id="flowEdges" class="flowEdgesSvg"></svg>
|
||||
<div id="flowCanvas" class="flowCanvas"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="floatPanelResizeHandle" id="logicBlockPanelResizeHandle"></div>
|
||||
</div>
|
||||
|
||||
<div class="builderTabPanel" id="animTabPanel" data-tab-panel="anim">
|
||||
@@ -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=<id> 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
|
||||
|
||||
@@ -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 <td>1</td> 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"<td>1</td>", 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
|
||||
Reference in New Issue
Block a user