Files
Forge-Engine/tests/test_confort.py
T
williamandClaude Sonnet 5 dbada333d5 Phase -1 : découpe le moteur de play.html en modules JS + premiers tests JS
templates/play.html était un unique fichier HTML+CSS+JS de 1069 lignes,
tout le moteur de jeu vivant dans UN SEUL <script>, sans aucune
couverture de test sur cette logique (seuls le rendu HTML et la syntaxe
JS étaient vérifiés). La feuille de route à venir (état par joueur,
hasard, clavier/minuteur, position/collision, son — voir le plan) va
justement faire grossir ce moteur : "un fichier = une fonction, un
dossier = une responsabilité" s'applique aussi au JS, pas seulement au
Python — le moment de découper est avant d'ajouter encore plus de code,
pas après.

Découpage en 6 fichiers sous static/js/play/, calqués sur les sections
déjà présentes dans le code (aucune réorganisation de logique, une pure
extraction) : screens.js (affichage d'écran, timeline d'animation),
conditions.js (évaluation des conditions — la partie 100% PURE, sans
DOM, la plus testable), actions.js (exécution des actions), triggers.js
(recherche des nœuds déclencheurs, attache des écouteurs), bindings.js
(résolution des {{champ}}, rafraîchissement des données), flow-engine.js
(parcours du graphe, événements personnalisés).

Zéro nouvel outillage : plusieurs <script src> dans l'ordre, partageant
le même espace global qu'avant (aucun bundler, aucune étape de build).
Les 2 URLs de route dont ces fichiers ont besoin (flow_node_run_data/
run_variable, runtime_payload) ne peuvent plus être injectées par Jinja
directement dans le code (un fichier statique n'est jamais passé par le
moteur de templates) — elles sont maintenant posées une fois dans
window.FORGE_PLAY_URLS par le petit <script> inline restant dans
play.html, qui ne porte plus que les données Jinja (gameData) et
l'amorçage (bindClicks() etc. au chargement).

publish/build_package.py : ajoute static/js/play à la liste des fichiers
copiés dans l'exécutable exporté (le mode jouable en dépend désormais).

Premiers tests JS (static/js/play/__tests__/conditions.test.js, lancés
via `node --test`, zéro nouvelle dépendance npm — decision prise avec
l'utilisateur de commencer par la logique PURE seulement, pas par une
couverture DOM via jsdom) : compareValues, resolveVariablePath,
evaluateConditionClause/Node, exactement la logique que les phases à
venir (opérations mathématiques, condition de collision) vont étendre.

tests/conftest.py : nouveau helper play_js_bundle() (concatène tout
static/js/play/*.js) — 13 tests existants qui vérifiaient la présence de
telle fonction/chaîne dans le HTML de /game/<slug>/play (tout le JS y
était inline avant ce découpage) sont mis à jour pour chercher dans ce
bundle à la place ; les tests qui vérifient un CSS/HTML réellement resté
dans play.html (forgeHighlight, forgeDisabled, #playFrame...) continuent
de chercher dans le HTML.

Vérifié : 215 tests pytest passent (aucune régression comportementale,
juste une réorganisation), 13 tests node:test passent, node --check sur
chacun des 6 nouveaux fichiers. Test manuel recommandé (jeu joué de bout
en bout : navigation, clic, survol, répéteur, condition, animation)
avant de considérer le découpage définitivement sans risque.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 13:59:11 +02:00

475 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
from conftest import play_js_bundle
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"."""
js = play_js_bundle()
assert "bindHoverTriggers" in js
assert "'survol'" in js
assert "'fin_survol'" in js
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):
assert "'contenu'" in play_js_bundle()
# ---------- 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):
js = play_js_bundle()
assert "'attendre'" in js
assert "setTimeout" in js
# ---------- 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 play_js_bundle()
# ---------- 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 play_js_bundle()