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/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/publish/build_package.py b/publish/build_package.py index 7ed1ffd0..a0c2e164 100644 --- a/publish/build_package.py +++ b/publish/build_package.py @@ -23,7 +23,7 @@ _BASE_DIR = os.path.dirname(_PUBLISH_DIR) # Sous-ensemble de static/ réellement nécessaire au mode jouable (voir # templates/play.html) — jamais les assets propres à l'éditeur seul # (icônes de l'interface de l'éditeur mises à part, déjà dans icons/). -_STATIC_ITEMS = ["style.css", "csrf_fetch.js", "icons", "branding", "vendor/fonts", "vendor/animate.min.css"] +_STATIC_ITEMS = ["style.css", "csrf_fetch.js", "icons", "branding", "vendor/fonts", "vendor/animate.min.css", "js/play"] _SLUG_PLACEHOLDER = "_SLUG_PLACEHOLDER" 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/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/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/js/play/__tests__/conditions.test.js b/static/js/play/__tests__/conditions.test.js new file mode 100644 index 00000000..f2d32384 --- /dev/null +++ b/static/js/play/__tests__/conditions.test.js @@ -0,0 +1,119 @@ +// Tests de la logique PURE du moteur (aucun accès DOM) — voir le plan de +// modularisation JS. Lancé via `node --test static/js/play/__tests__/`. +// Ces fonctions lisent des globals `gameData`/`window` (posés par +// play.html en navigateur) — on les simule ici avant chaque test. +const test = require('node:test'); +const assert = require('node:assert/strict'); + +global.window = global.window || {}; +const { + compareValues, + resolveVariablePath, + readFieldValue, + readVariableValue, + evaluateConditionClause, + evaluateConditionNode, +} = require('../conditions.js'); + +function withGameData(data, fn) { + const previous = global.gameData; + global.gameData = data; + try { + fn(); + } finally { + global.gameData = previous; + } +} + +test('compareValues — comparaison numérique', () => { + assert.equal(compareValues('10', 'superieur', '5'), true); + assert.equal(compareValues('3', 'inferieur', '5'), true); + assert.equal(compareValues('5', 'egal', '5'), true); + assert.equal(compareValues('5', 'different', '5'), false); +}); + +test('compareValues — repli sur comparaison de chaînes si non numérique', () => { + assert.equal(compareValues('abc', 'egal', 'abc'), true); + assert.equal(compareValues('abc', 'different', 'xyz'), true); +}); + +test('compareValues — champ booléen, vocabulaire oui/non insensible à la casse', () => { + assert.equal(compareValues(true, 'egal', 'Oui', 'booleen'), true); + assert.equal(compareValues(false, 'egal', 'non', 'booleen'), true); + assert.equal(compareValues(true, 'different', 'non', 'booleen'), true); +}); + +test('resolveVariablePath — chemin vide renvoie la valeur brute', () => { + assert.equal(resolveVariablePath('42', ''), '42'); +}); + +test('resolveVariablePath — chemin .champ / [index] chaînable', () => { + const raw = JSON.stringify({ inventaire: [{ nom: 'clé' }, { nom: 'épée' }] }); + assert.equal(resolveVariablePath(raw, '.inventaire[1].nom'), 'épée'); +}); + +test('resolveVariablePath — JSON invalide ou chemin absent renvoie null', () => { + assert.equal(resolveVariablePath('{pas du json', '.x'), null); + assert.equal(resolveVariablePath('{"a":1}', '.b.c'), null); +}); + +test('readFieldValue — résout CLICKED_ROW_ID (-1) via window.lastClickedRowId', () => { + withGameData({ data: { '1': [{ id: 7, score: 42 }] } }, () => { + window.lastClickedRowId = 7; + assert.equal(readFieldValue(1, -1, 'score'), 42); + }); +}); + +test('readVariableValue — lit gameData.variables par nom', () => { + withGameData({ variables: { points: { type: 'nombre_entier', value: '10' } } }, () => { + assert.equal(readVariableValue('points'), '10'); + assert.equal(readVariableValue('inconnue'), undefined); + }); +}); + +test('evaluateConditionClause — source "objet" (comportement historique)', () => { + withGameData({ data: { '1': [{ id: 5, pv: '3' }] } }, () => { + const clause = { cond_definition_id: 1, cond_row_id: 5, cond_field: 'pv', cond_operator: 'inferieur', cond_value: '10' }; + assert.equal(evaluateConditionClause(clause), true); + }); +}); + +test('evaluateConditionClause — source "variable"', () => { + withGameData({ variables: { pv: { type: 'nombre_entier', value: '3' } } }, () => { + const clause = { cond_source: 'variable', cond_variable: 'pv', cond_operator: 'inferieur', cond_value: '10' }; + assert.equal(evaluateConditionClause(clause), true); + }); +}); + +test('evaluateConditionNode — clause unique (pas de cond_clauses)', () => { + withGameData({ variables: { pv: { type: 'nombre_entier', value: '3' } } }, () => { + const node = { cond_source: 'variable', cond_variable: 'pv', cond_operator: 'egal', cond_value: '3' }; + assert.equal(evaluateConditionNode(node), true); + }); +}); + +test('evaluateConditionNode — clauses combinées ET (toutes vraies requises)', () => { + withGameData({ variables: { pv: { type: 'nombre_entier', value: '3' }, or: { type: 'nombre_entier', value: '0' } } }, () => { + const node = { + cond_combinator: 'et', + cond_clauses: JSON.stringify([ + { source: 'variable', variable: 'pv', operator: 'egal', value: '3' }, + { source: 'variable', variable: 'or', operator: 'egal', value: '0' }, + ]), + }; + assert.equal(evaluateConditionNode(node), true); + }); +}); + +test('evaluateConditionNode — clauses combinées OU (une seule vraie suffit)', () => { + withGameData({ variables: { pv: { type: 'nombre_entier', value: '3' }, or: { type: 'nombre_entier', value: '0' } } }, () => { + const node = { + cond_combinator: 'ou', + cond_clauses: JSON.stringify([ + { source: 'variable', variable: 'pv', operator: 'egal', value: '999' }, + { source: 'variable', variable: 'or', operator: 'egal', value: '0' }, + ]), + }; + assert.equal(evaluateConditionNode(node), true); + }); +}); diff --git a/static/js/play/actions.js b/static/js/play/actions.js new file mode 100644 index 00000000..220f8a8c --- /dev/null +++ b/static/js/play/actions.js @@ -0,0 +1,193 @@ +// ---------- Exécution des actions ---------- +// Extrait de templates/play.html (voir plan de modularisation) : +// application d'une propriété d'élément ("Modifier un élément") et +// exécution de chaque type de nœud Action. Les URLs de routes serveur +// (flow_node_run_data/run_variable) sont injectées par play.html dans +// window.FORGE_PLAY_URLS (données Jinja, jamais codées en dur ici). + +// Propriété CSS réelle + éventuel suffixe (unité) pour chaque propriété +// pilotable par une action "Modifier un élément". +var ELEMENT_PROPERTY_CSS = { + bg_color: { css: 'backgroundColor', suffix: '' }, + border_color: { css: 'borderColor', suffix: '' }, + width: { css: 'width', suffix: '%' }, + height: { css: 'height', suffix: '%' }, +}; + +function applyElementProperty(targetEl, property, value) { + if (property === 'contenu') { + // Voir ELEMENT_ACTION_PROPERTIES ("contenu") : texte littéral, pas de + // binding vers un champ d'objet ici (contrairement à + // applyOpenRowBindings, propre à "Ouvrir la ligne cliquée"). Comme + // pour les autres réglages ci-dessous, cible n'importe quel élément — + // pointer ceci vers un conteneur remplacerait tout son contenu + // imbriqué, à éviter. + targetEl.textContent = value == null ? '' : value; + return; + } + if (property === 'surbrillance') { + // 3.3 (Confort) — surbrillance générique dynamique : un liseré qui + // clignote (voir la classe .forgeHighlight et son animation dans le + //