Files
Forge-Engine/tests/test_confort.py
T
williamandClaude Sonnet 5 8cffbeac68 Donnée liée : conditions ET/OU illimitées + conditions de logique sur une variable globale
Deux fonctionnalités demandées, développées et corrigées dans cet
échange :

1. "Donnée liée" (Texte/Titre) : le réglage à 2 filtres fixes (toujours
   combinés en ET) devient une liste de conditions ILLIMITÉE, avec un
   choix ET/OU pour les combiner (screens/widgets/controls/c_clause_list.py,
   screens/clause_list_codec.py). Rétrocompatible avec les anciens
   éléments (_data_filtre_champ/_data_filtre2_champ), convertis à la
   volée à la lecture, sans migration. Après un premier essai à la
   présentation trop compacte et technique (retour utilisateur : "pas de
   champ technique, pas de notation bizarre {{ }}"), la présentation
   finale reprend EXACTEMENT l'ancien style (labels "Champ"/"...est"/
   "...cette valeur", même sélecteur de valeur fixe/dynamique/variable
   déjà existant, jamais la syntaxe brute), simplement répétée par
   condition (templates/partials/clause_row.html), avec un bouton
   "+ Ajouter une condition" bien visible et une liste scrollable
   (static/style.css, .clauseListWrap). Le même moteur (filter_repeater_
   rows.py généralisé) profite aussi au Répéteur de données en interne.

2. Nœud Condition de la Logique de la scène : peut désormais tester une
   VARIABLE GLOBALE en plus d'un champ d'objet (cond_source/cond_variable/
   cond_variable_chemin — screens/flow/ensure_flow_schema.py), sur la
   clause principale ET chaque clause supplémentaire (ET/OU). Évalué côté
   CLIENT (templates/play.html, evaluateConditionClause), contre un
   nouveau gameData.variables exposé par full_game_payload.py — tenu à
   jour par refreshRuntimeData() après toute action qui modifie une
   variable, sans changement supplémentaire nécessaire. Le panneau de
   condition reste utilisable même sans aucun objet défini dans le jeu
   (avant, il disparaissait entièrement).

Vérifié : 153 tests pytest (nouveaux : test_data_binding_clause_list.py,
test_condition_variable.py) + logique JS d'évaluation des conditions
vérifiée isolément avec Node (variable scalaire, objet avec chemin
chaîné, tableau par index, variable introuvable, booléen, rétrocompatibilité
legacy) + rendu des deux pages (éditeur/jeu) vérifié sur le vrai projet
"test" en plus des jeux de test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 09:05:02 +02:00

477 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Tests des points "Confort" (section 3 de la fiche de cadrage) :
3.1 interactions au survol, 3.2 séquences temporisées, 3.3 surbrillance
générique dynamique, 3.4 overlay/modale réutilisable, 3.5 verrouillage
d'un élément après décision."""
import json
import re
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):
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))
# ---------- 3.1 — Interactions au survol ----------
def test_hover_text_control_removed_from_properties_panel(client, game):
"""Le réglage "Survol" a été retiré du panneau de propriétés (voir
universal_controls.py) : survoler un élément est conceptuellement un
déclencheur de la Logique de la scène, pas une propriété statique —
reconstruit là-bas avec les déclencheurs "survol"/"fin_survol" (voir
plus bas). Poster ctrl_survol_texte ne doit donc plus avoir d'effet (le
mécanisme data-hover-text/bindHoverTexts sous-jacent reste en place, il
n'est simplement plus réglable depuis ce panneau)."""
screen_id = _create_screen(client, game)
el_id = _add_element(client, game, screen_id, "titre")
client.post(f"/game/{game}/elements/{el_id}/save", data={
"ctrl_content": "Mathilde Dubois", "ctrl_survol_texte": "mathilde.d@forgebase.fr",
})
resp = client.get(f"/game/{game}/play")
html = resp.data.decode()
assert 'data-hover-text="' not in html
def test_hover_text_absent_by_default_no_regression(client, game):
"""Sans réglage de survol, aucun attribut data-hover-text ne doit
apparaître — aucune régression sur les éléments existants."""
screen_id = _create_screen(client, game)
_add_element(client, game, screen_id, "titre")
resp = client.get(f"/game/{game}/play")
html = resp.data.decode()
# (le mot "data-hover-text" apparaît dans un commentaire JS du moteur
# lui-même — on vérifie donc l'absence de l'ATTRIBUT réellement posé sur
# un élément, pas la simple présence de la chaîne dans la page)
assert 'data-hover-text="' not in html
def test_play_page_exposes_hover_binding_runtime(client, game):
resp = client.get(f"/game/{game}/play")
html = resp.data.decode()
assert "bindHoverTexts" in html
def test_survol_trigger_node_persists(client, game):
screen_id = _create_screen(client, game)
el_id = _add_element(client, game, screen_id, "titre")
resp = client.post(
f"/game/{game}/screens/{screen_id}/flow/nodes/add",
data=json.dumps({"node_type": "trigger", "trigger_element_id": el_id, "trigger_event": "survol"}),
content_type="application/json",
)
assert resp.status_code == 200
node = resp.get_json()
assert node["trigger_event"] == "survol"
assert node["trigger_element_id"] == el_id
def test_play_page_exposes_hover_trigger_runtime(client, game):
"""bindHoverTriggers() (mouseenter/mouseleave -> runFlowFrom) doit être
exposé et appelé, exactement comme bindClicks() pour "Au clic"."""
resp = client.get(f"/game/{game}/play")
html = resp.data.decode()
assert "bindHoverTriggers" in html
assert "'survol'" in html
assert "'fin_survol'" in html
def test_modifier_element_contenu_action_persists(client, game):
""""Modifier un élément → Contenu" : c'est ce qui permet de reconstruire
l'ancien "texte affiché au survol" (et bien d'autres usages) à la main
dans la Logique de la scène, en le combinant avec un déclencheur
"Au survol" posé sur un AUTRE élément."""
screen_id = _create_screen(client, game)
source_id = _add_element(client, game, screen_id, "titre")
target_id = _add_element(client, game, screen_id, "texte")
resp = client.post(
f"/game/{game}/screens/{screen_id}/flow/nodes/add",
data=json.dumps({
"node_type": "action", "action_type": "modifier_element",
"target_element_id": target_id, "element_property": "contenu", "element_value": "Survol actif !",
}),
content_type="application/json",
)
assert resp.status_code == 200
node = resp.get_json()
assert node["element_property"] == "contenu"
assert node["element_value"] == "Survol actif !"
assert node["target_element_id"] == target_id
# source_id n'est utilisé que pour documenter le scénario (le
# déclencheur "Au survol" se poserait dessus) — non exercé ici, déjà
# couvert par test_survol_trigger_node_persists.
assert source_id != target_id
def test_play_page_exposes_contenu_property_runtime(client, game):
resp = client.get(f"/game/{game}/play")
html = resp.data.decode()
assert "'contenu'" in html
# ---------- 3.2 — Séquences temporisées ----------
def test_attendre_action_node_persists_delay(client, game):
screen_id = _create_screen(client, game)
resp = client.post(
f"/game/{game}/screens/{screen_id}/flow/nodes/add",
data=json.dumps({"node_type": "action", "action_type": "attendre", "data_value": "5"}),
content_type="application/json",
)
assert resp.status_code == 200
node = resp.get_json()
assert node["action_type"] == "attendre"
assert node["data_value"] == "5"
def test_play_page_exposes_wait_action_runtime(client, game):
resp = client.get(f"/game/{game}/play")
html = resp.data.decode()
assert "'attendre'" in html
assert "setTimeout" in html
# ---------- 3.3 — Surbrillance générique dynamique ----------
def test_surbrillance_action_node_persists(client, game):
screen_id = _create_screen(client, game)
el_id = _add_element(client, game, screen_id, "bouton")
resp = client.post(
f"/game/{game}/screens/{screen_id}/flow/nodes/add",
data=json.dumps({
"node_type": "action", "action_type": "modifier_element",
"target_element_id": el_id, "element_property": "surbrillance", "element_value": "on",
}),
content_type="application/json",
)
assert resp.status_code == 200
node = resp.get_json()
assert node["element_property"] == "surbrillance"
assert node["element_value"] == "on"
def test_play_page_exposes_highlight_runtime_and_css(client, game):
resp = client.get(f"/game/{game}/play")
html = resp.data.decode()
assert "forgeHighlight" in html
assert "'surbrillance'" in html
# ---------- 3.4 — Overlay / modale réutilisable ----------
def test_overlay_widget_renders_fullscreen_fixed_box(client, game):
screen_id = _create_screen(client, game)
overlay_id = _add_element(client, game, screen_id, "superposition")
client.post(f"/game/{game}/elements/{overlay_id}/save", data={"ctrl_couleur_boite": "#222222", "ctrl_arrondi": "20"})
client.post(f"/game/{game}/elements/{overlay_id}/children/add", data={"widget": "titre"})
resp = client.get(f"/game/{game}/play")
html = resp.data.decode()
idx = html.find(f'data-element-id="{overlay_id}"')
assert idx != -1
# Le tag qui porte data-element-id est celui rendu par _render_overlay :
# on regarde tout son contenu de balise ouvrante (jusqu'au ">" suivant)
# ainsi que ce qui suit immédiatement (la boîte centrée à l'intérieur).
tag_start = html.rfind("<div", 0, idx)
snippet = html[tag_start:idx + 400]
assert "position:fixed" in snippet
assert "forgeOverlayBox" in snippet
def test_overlay_box_default_text_color_survives_the_bulma_box_class(client, game):
"""Régression : la classe Bulma ".box" (voir render_overlay.py) impose
elle-même une couleur de texte SOMBRE, pensée pour un fond blanc. Un
texte posé dans la boîte SANS couleur personnalisée (le cas par défaut
— aucun widget ne fige de couleur à sa création, voir
default_style_for_widget.py) héritait donc de ce gris sombre, invisible
sur le fond sombre par défaut de la boîte — "j'ai mis un texte dedans
mais il ne se voit pas", sans la moindre erreur serveur. La boîte doit
donc fixer elle-même une couleur de texte claire par défaut, que Bulma
ne peut plus écraser (élément le plus proche gagne)."""
screen_id = _create_screen(client, game)
overlay_id = _add_element(client, game, screen_id, "superposition")
client.post(f"/game/{game}/elements/{overlay_id}/children/add", data={"widget": "texte"})
resp = client.get(f"/game/{game}/play")
html = resp.data.decode()
idx = html.find("forgeOverlayBox")
assert idx != -1
box_tag = html[html.rfind("<div", 0, idx):idx + 200]
assert "color:#e8eaf0" in box_tag
def test_visibility_dropdown_reflects_masque_even_when_visible_would_also_match(client, game):
"""Régression (la vraie cause derrière "je ne vois plus ma modale, ni
même en changeant Visibilité sur Visible") : le panneau de propriétés
détecte la valeur ACTUELLE d'un réglage "preset" (screens/widgets/
control_value.py) en cherchant la PREMIÈRE option de la liste dont les
critères correspondent au style stocké. L'option "Visible normalement"
de la Visibilité (visibility_control.py) ne vérifie QUE "visibility"
(jamais "display", par choix assumé) — un élément "Masqué" (qui ne pose
que "display:none", jamais "visibility") satisfaisait donc TOUJOURS,
trivialement, les critères de "Visible" en premier (testée avant
"Masqué" dans la liste) : le panneau affichait "Visible normalement"
sélectionné sur un élément EN RÉALITÉ masqué. Comme un <select> ne
déclenche un enregistrement que sur un changement RÉEL de valeur,
re-choisir l'option déjà affichée ne faisait RIEN : impossible de
rendre l'élément visible depuis le panneau. Corrigé en vérifiant les
options les plus SPÉCIFIQUES (le plus de propriétés non vides exigées)
en premier."""
from screens.widgets.control_value import _control_value
from screens.widgets.widget_meta import widget_meta
import screens
screen_id = _create_screen(client, game)
overlay_id = _add_element(client, game, screen_id, "superposition")
el = screens.get_element(game, overlay_id)
meta = widget_meta(el["widget"])
vis_control = next(c for c in meta["controls"] if c["key"] == "visibilite")
# "Masqué" par défaut à la création (default_style_for_widget.py) : le
# panneau doit détecter "masque", pas "visible".
assert _control_value(el, vis_control) == "masque"
def test_overlay_behaves_like_a_normal_container_in_the_editor(client, game):
"""Régression (deux retours utilisateur successifs) :
1. Un essai précédent forçait "display:flex" dans l'éditeur, quel que
soit le réglage "Visibilité" -> "je veux avoir la main sur la
visibilité de la modale, sinon elle s'affiche toujours sur la
scène [éditeur] et court-circuite ma logique". L'éditeur doit donc
respecter "Visibilité" normalement, EXACTEMENT comme n'importe quel
autre widget (masqué = display:none aussi dans l'éditeur).
2. La boîte gardait son voile plein écran (position:fixed + fond
assombri) même dans l'éditeur -> "je souhaite que rien ne soit
assombri, l'assombrissement ne se fait que quand la scène est
jouée". Le voile plein écran ne doit donc apparaître qu'en mode
JOUABLE — dans l'éditeur, ce widget se comporte comme un conteneur
normal (position/taille selon x/y/width/height, pas de voile)."""
screen_id = _create_screen(client, game)
overlay_id = _add_element(client, game, screen_id, "superposition")
client.post(f"/game/{game}/elements/{overlay_id}/children/add", data={"widget": "texte"})
# Par défaut ("Masqué" à la création) : invisible dans l'éditeur aussi,
# comme n'importe quel autre widget masqué (pas de forçage).
edit_html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
idx = edit_html.find(f'id="elt-{overlay_id}"')
assert idx != -1
tag = edit_html[edit_html.rfind("<div", 0, idx):idx + 30]
assert "display:none" in tag
assert "position:fixed" not in tag
assert "rgba(0,0,0,0.6)" not in tag
# Repassé "Visible" à la main (garder la main sur la visibilité) :
# apparaît dans l'éditeur, mais toujours SANS voile plein écran.
client.post(f"/game/{game}/elements/{overlay_id}/save", data={"ctrl_visibilite": "visible"})
edit_html2 = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
idx2 = edit_html2.find(f'id="elt-{overlay_id}"')
tag2 = edit_html2[edit_html2.rfind("<div", 0, idx2):idx2 + 30]
assert "display:none" not in tag2
assert "position:fixed" not in tag2
assert "rgba(0,0,0,0.6)" not in tag2
# En mode JOUABLE, le comportement plein écran + voile reste inchangé.
play_html = client.get(f"/game/{game}/play").data.decode()
idx3 = play_html.find(f'id="elt-{overlay_id}"')
tag3 = play_html[play_html.rfind("<div", 0, idx3):idx3 + 250]
assert "position:fixed" in tag3
assert "rgba(0,0,0,0.6)" in tag3
def test_overlay_element_type_instance_has_no_visible_wrapper_box(client, game):
"""Régression : poser un élément de jeu réutilisable ("Mes éléments de
jeu") dont le MODÈLE n'est qu'une superposition affichait, sur la
vraie scène, une boîte "conteneur" vide et TOUJOURS VISIBLE à
l'endroit où l'exemplaire a été déposé — "un conteneur vide apparaît,
pas la boîte de dialogue" (elle existe bien, masquée comme prévu ;
c'est l'enveloppe générique "conteneur" AUTOUR, posée par défaut pour
tout exemplaire (add_element.py), qui n'aurait jamais dû être visible
pour un modèle qui n'est QUE ça). Corrigé en court-circuitant cette
enveloppe dès que le modèle entier est une superposition."""
import screens
resp = client.post(f"/game/{game}/element-types", data={"name": "Dialogue"}, follow_redirects=False)
assert resp.status_code == 302
et = next(t for t in screens.list_element_types(game) if t["name"] == "Dialogue")
template_screen_id = et["template_screen_id"]
overlay_id = _add_element(client, game, template_screen_id, "superposition")
client.post(f"/game/{game}/elements/{overlay_id}/children/add", data={"widget": "texte"})
screen_id = _create_screen(client, game, "Scène")
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add",
data={"widget": "__catalogue__", "element_type_id": et["id"]}, follow_redirects=False)
assert resp.status_code == 302
# Un frère ajouté APRÈS -> z_index plus grand, comme dans le scénario
# rapporté (une scène avec d'autres éléments déjà en place).
_add_element(client, game, screen_id, "bouton")
html = client.get(f"/game/{game}/play").data.decode()
assert "forgeOverlayBox" in html
assert 'class="box"' not in html # l'enveloppe "conteneur" par défaut ne doit plus apparaître
idx = html.find('class="modal is-active"')
wrapper_start = html.rfind('<div class="playElement"', 0, idx)
wrapper_style = re.search(r'style="([^"]*)"', html[wrapper_start:idx]).group(1)
assert "z-index" not in wrapper_style
def test_overlay_element_type_instance_is_controllable_from_the_hosting_scene(client, game):
"""Régression : la logique posée sur LA SCÈNE qui accueille un
exemplaire de dialogue (ex. "Modifier un élément → Modale : Visibilité
= Rendre visible", ciblant l'exemplaire par son id SUR CETTE SCÈNE)
n'avait plus aucun effet une fois l'enveloppe "conteneur" entièrement
court-circuitée (un essai précédent) : son id disparaissait du DOM
(impossible à cibler), et même en le gardant, la superposition INTERNE
au modèle restait masquée indépendamment (double masquage — rendre
l'enveloppe visible n'aurait rien changé). Corrigé : l'enveloppe
GARDE son propre id/data-element-id (ciblable depuis la scène), et la
superposition interne au modèle ignore désormais son propre réglage
"Visibilité" une fois posée comme exemplaire — tout le masquage est
délégué à l'enveloppe (qui démarre elle-même masquée par défaut, voir
add_element.py)."""
import screens
resp = client.post(f"/game/{game}/element-types", data={"name": "Dialogue2"}, follow_redirects=False)
et = next(t for t in screens.list_element_types(game) if t["name"] == "Dialogue2")
overlay_id = _add_element(client, game, et["template_screen_id"], "superposition")
client.post(f"/game/{game}/elements/{overlay_id}/children/add", data={"widget": "texte"})
screen_id = _create_screen(client, game, "Scène2")
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add",
data={"widget": "__catalogue__", "element_type_id": et["id"]}, follow_redirects=False)
instance_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
html = client.get(f"/game/{game}/play").data.decode()
idx = html.find(f'data-element-id="{instance_id}"')
assert idx != -1
wrapper_tag = html[html.rfind("<div", 0, idx):idx + 40]
assert "display:none" in wrapper_tag # masqué par défaut, sur l'EXEMPLAIRE
# La superposition interne au modèle, elle, ne doit PLUS porter son
# propre display:none une fois rendue comme exemplaire (sinon la
# rendre visible depuis la scène resterait sans effet).
idx_modal = html.find('class="modal is-active"', idx)
modal_tag = html[html.rfind("<div", 0, idx_modal):idx_modal + 250]
assert "display:none" not in modal_tag
trig = client.post(f"/game/{game}/screens/{screen_id}/flow/nodes/add",
data=json.dumps({"node_type": "trigger", "trigger_event": "affichage"}),
content_type="application/json").get_json()
act = client.post(f"/game/{game}/screens/{screen_id}/flow/nodes/add",
data=json.dumps({
"node_type": "action", "action_type": "modifier_element",
"target_element_id": instance_id, "element_property": "visibilite", "element_value": "visible",
}), content_type="application/json").get_json()
assert act["target_element_id"] == instance_id
def test_overlay_wrapper_does_not_trap_its_own_z_index(client, game):
"""Régression : le cadre .playElement/.canvasElement partagé par TOUS
les widgets (voir filters/element_style_filter.py) posait quand même
"z-index:<sa place dans le canevas>" sur la superposition, MÊME SI son
propre contenu (render_overlay.py) ignore x/y/width/height et pose déjà
position:fixed + z-index:9999 lui-même. Un élément positionné avec un
z-index EXPLICITE crée un NOUVEAU contexte d'empilement CSS : le 9999
posé plus profond ne se comparait alors plus qu'AU SEIN de ce contexte,
et perdait face au z-index (plus grand) d'un élément normal ajouté
APRÈS l'overlay sur le canevas — qui s'affichait donc PAR-DESSUS le
dialogue censé tout couvrir. Le cadre garde position/left/top/width/
height comme tout widget (l'éditeur en a besoin pour glisser-déposer/
redimensionner ce cadre — les retirer a fait planter element_geometry
en régression), mais n'écrit plus DU TOUT de z-index pour ce widget :
position:absolute avec z-index:auto (omis) ne crée pas de contexte
d'empilement, donc le 9999 se compare directement aux autres éléments."""
screen_id = _create_screen(client, game)
overlay_id = _add_element(client, game, screen_id, "superposition")
# Ajouté APRÈS l'overlay -> z_index plus grand que le sien.
_add_element(client, game, screen_id, "bouton")
resp = client.get(f"/game/{game}/play")
html = resp.data.decode()
idx = html.find(f'data-el-id="{overlay_id}"')
assert idx != -1
tag_start = html.rfind("<div", 0, idx)
wrapper_tag = html[tag_start:idx + 200]
assert "position:absolute" in wrapper_tag
assert "z-index" not in wrapper_tag
def test_overlay_geometry_can_still_be_saved_from_the_editor(client, game):
"""Régression : la première version du correctif ci-dessus retirait
AUSSI position/left/top/width/height du cadre d'une superposition, ce
qui effondrait ce cadre à 0×0 dans l'ÉDITEUR (son contenu réel est en
position:fixed, hors flux) — le calcul de glisser-déposer/
redimensionnement (screen_edit.html) divise alors par une dimension
nulle, produit NaN, et JSON.stringify(NaN) envoie "null" : le serveur
plantait sur float(None) dans element_geometry.py. Le cadre doit donc
continuer à porter une position/taille en % normale pour ce widget."""
screen_id = _create_screen(client, game)
overlay_id = _add_element(client, game, screen_id, "superposition")
resp = client.post(
f"/game/{game}/elements/{overlay_id}/geometry",
data=json.dumps({"x": 12.5, "y": 20, "width": 55, "height": 45}),
content_type="application/json",
)
assert resp.status_code == 200
def test_overlay_starts_hidden_by_default(client, game):
"""Régression : une Superposition fraîchement posée ne doit PAS couvrir
tout l'écran dès sa création (position:fixed + inset:0 la ferait sinon
intercepter tous les clics de l'écran) — elle démarre masquée, à ouvrir
explicitement via une action."""
screen_id = _create_screen(client, game)
overlay_id = _add_element(client, game, screen_id, "superposition")
resp = client.get(f"/game/{game}/play")
html = resp.data.decode()
idx = html.find(f'data-element-id="{overlay_id}"')
assert idx != -1
tag_start = html.rfind("<div", 0, idx)
snippet = html[tag_start:idx]
assert "display:none" in snippet
def test_overlay_can_be_hidden_and_shown_like_any_element(client, game):
"""La fermeture manuelle de l'overlay réutilise l'action existante
"Modifier un élément → Visibilité" — pas de mécanisme dédié."""
screen_id = _create_screen(client, game)
overlay_id = _add_element(client, game, screen_id, "superposition")
resp = client.post(
f"/game/{game}/screens/{screen_id}/flow/nodes/add",
data=json.dumps({
"node_type": "action", "action_type": "modifier_element",
"target_element_id": overlay_id, "element_property": "visibilite", "element_value": "masque",
}),
content_type="application/json",
)
assert resp.status_code == 200
node = resp.get_json()
assert node["element_property"] == "visibilite"
assert node["target_element_id"] == overlay_id
# ---------- 3.5 — Verrouillage d'un élément après décision ----------
def test_desactive_action_node_persists(client, game):
screen_id = _create_screen(client, game)
el_id = _add_element(client, game, screen_id, "bouton")
resp = client.post(
f"/game/{game}/screens/{screen_id}/flow/nodes/add",
data=json.dumps({
"node_type": "action", "action_type": "modifier_element",
"target_element_id": el_id, "element_property": "desactive", "element_value": "on",
}),
content_type="application/json",
)
assert resp.status_code == 200
node = resp.get_json()
assert node["element_property"] == "desactive"
assert node["element_value"] == "on"
def test_play_page_exposes_lock_runtime_and_css(client, game):
resp = client.get(f"/game/{game}/play")
html = resp.data.decode()
assert "forgeDisabled" in html
assert "pointer-events:none" in html
assert "'desactive'" in html