Ajoute les événements personnalisés (backend) : déclencher/écouter
Première moitié de la fonctionnalité "événements" (NEED_ACTION et autres) : une entité game-wide (nom, description, "a un paramètre élément" oui/non), déclenchable comme nouvelle action du graphe de logique depuis n'importe quelle scène/modèle, et écoutable comme nouveau type de déclencheur depuis n'importe quel autre. L'UI (nouvel onglet "Événements" dans screen_edit.html, formulaires de nœud, exécution côté client dans play.html) suit dans un commit séparé. - db/custom_events/ (calqué sur db/global_vars/) : CRUD de la table _custom_events (nom unique, description, has_element_param). create_custom_event est idempotent par nom (même convention que create_global_variable) — sans risque en cas de double soumission. - screens/flow/ : 3 nouvelles colonnes sur _flow_nodes (trigger_custom_event_id/target_custom_event_id : quel événement un nœud écoute/déclenche ; target_element_from_event : indicateur réutilisable par n'importe quel nœud Action utilisant déjà target_element_id, pour résoudre "l'élément transmis par l'événement en cours" au lieu d'une cible fixe — contourne la contrainte de clé étrangère de target_element_id, qui empêche d'y stocker un sentinel comme EVENT_ROW_ID directement). Nouveau trigger_event "evenement" et action_type "declencher_evenement". - screens/custom_events/ (PAS dans db/, même séparation que screens/elements/delete_element.py) : delete_custom_event, la SEULE suppression d'entité game-wide du moteur à vraiment cascader (demande explicite) — supprime tous les nœuds/arêtes qui référencent l'événement, sur TOUTES les scènes ET tous les modèles à la fois (aucun filtre screen_id nécessaire : un modèle est un écran caché, même table _flow_nodes). list_custom_event_usages : où un événement est écouté/déclenché, pour l'onglet Événements à venir. - routes/custom_events/ : CRUD monté sous /game/<slug>/events/..., redirige vers l'éditeur de scène/modèle d'origine (screen_id transmis par le formulaire) avec l'onglet "events" à ouvrir. tests/test_custom_events.py (nouveau) : idempotence à la création, usages détectés sur deux écrans différents, suppression qui retire bien les DEUX nœuds (un sur une vraie scène, un sur un modèle/écran caché) en une seule opération, sans toucher aux écrans eux-mêmes. 207 tests au total. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
1289079da5
commit
dcbec16818
@@ -0,0 +1,112 @@
|
||||
"""Événements personnalisés (db/custom_events/, screens/custom_events/) :
|
||||
un événement game-wide (ex. NEED_ACTION) écouté par un nœud Déclencheur
|
||||
("trigger_event"="evenement") sur UNE scène/UN modèle, et déclenché par
|
||||
un nœud Action ("action_type"="declencher_evenement") sur UNE AUTRE
|
||||
scène/UN AUTRE modèle — la seule entité game-wide du moteur dont la
|
||||
suppression cascade RÉELLEMENT vers les nœuds qui la référencent,
|
||||
partout à la fois (voir screens/custom_events/delete_custom_event.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_element(client, slug, screen_id, widget="bouton"):
|
||||
resp = client.post(f"/game/{slug}/screens/{screen_id}/elements/add", data={"widget": widget}, follow_redirects=False)
|
||||
return int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
|
||||
|
||||
def _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 test_create_event_is_idempotent_by_name(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
resp = client.post(f"/game/{game}/events/new", data={
|
||||
"name": "NEED_ACTION", "description": "Besoin d'action", "has_element_param": "on", "screen_id": screen_id,
|
||||
})
|
||||
assert resp.status_code == 302
|
||||
events = db.list_custom_events(game)
|
||||
assert len(events) == 1
|
||||
assert events[0]["name"] == "NEED_ACTION"
|
||||
assert events[0]["has_element_param"] == 1
|
||||
|
||||
# Double soumission : ne crée pas un deuxième événement du même nom.
|
||||
client.post(f"/game/{game}/events/new", data={"name": "NEED_ACTION", "screen_id": screen_id})
|
||||
assert len(db.list_custom_events(game)) == 1
|
||||
|
||||
|
||||
def test_edit_event_updates_description_and_param_flag(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
event_id = db.create_custom_event(game, "NEED_ACTION", "old", False)
|
||||
resp = client.post(f"/game/{game}/events/{event_id}/edit", data={
|
||||
"description": "new", "has_element_param": "on", "screen_id": screen_id,
|
||||
})
|
||||
assert resp.status_code == 302
|
||||
event = db.get_custom_event(game, event_id)
|
||||
assert event["description"] == "new"
|
||||
assert event["has_element_param"] == 1
|
||||
|
||||
|
||||
def test_usages_reports_listener_and_dispatcher_across_different_screens(client, game):
|
||||
event_id = db.create_custom_event(game, "NEED_ACTION", "", True)
|
||||
|
||||
listener_screen = _create_screen(client, game, "Écran écouteur")
|
||||
_add_flow_node(client, game, listener_screen, node_type="trigger", trigger_event="evenement", trigger_custom_event_id=event_id)
|
||||
|
||||
dispatcher_screen = _create_screen(client, game, "Écran déclencheur")
|
||||
element_id = _add_element(client, game, dispatcher_screen)
|
||||
_add_flow_node(
|
||||
client, game, dispatcher_screen,
|
||||
node_type="action", action_type="declencher_evenement",
|
||||
target_custom_event_id=event_id, target_element_id=element_id,
|
||||
)
|
||||
|
||||
usages = screens.list_custom_event_usages(game)
|
||||
roles = {u["role"] for u in usages[event_id]}
|
||||
screen_ids = {u["screen_id"] for u in usages[event_id]}
|
||||
assert roles == {"ecoute", "declenche"}
|
||||
assert screen_ids == {listener_screen, dispatcher_screen}
|
||||
|
||||
|
||||
def test_deleting_an_event_cascades_to_every_referencing_node_everywhere(client, game):
|
||||
event_id = db.create_custom_event(game, "NEED_ACTION", "", True)
|
||||
|
||||
listener_screen = _create_screen(client, game, "Écran écouteur")
|
||||
listener_node_id = _add_flow_node(
|
||||
client, game, listener_screen,
|
||||
node_type="trigger", trigger_event="evenement", trigger_custom_event_id=event_id,
|
||||
)
|
||||
|
||||
# Le modèle (élément de jeu réutilisable) est un écran caché — voir
|
||||
# screens.create_element_type — le déclencheur y est posé exactement
|
||||
# comme sur une vraie scène.
|
||||
screens.create_element_type(game, "Carte email", "📧")
|
||||
template = next(t for t in screens.list_element_types(game) if t["name"] == "Carte email")
|
||||
template_screen_id = template["template_screen_id"]
|
||||
dispatcher_node_id = _add_flow_node(
|
||||
client, game, template_screen_id,
|
||||
node_type="action", action_type="declencher_evenement", target_custom_event_id=event_id,
|
||||
)
|
||||
|
||||
resp = client.post(f"/game/{game}/events/{event_id}/delete", data={"screen_id": listener_screen}, follow_redirects=False)
|
||||
assert resp.status_code == 302
|
||||
|
||||
assert db.get_custom_event(game, event_id) is None
|
||||
assert screens.get_flow_node(game, listener_node_id) is None
|
||||
assert screens.get_flow_node(game, dispatcher_node_id) is None
|
||||
# Les écrans/modèles eux-mêmes ne sont pas touchés, seuls les nœuds.
|
||||
assert screens.get_screen(game, listener_screen) is not None
|
||||
assert screens.get_screen(game, template_screen_id) is not None
|
||||
Reference in New Issue
Block a user