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>
475 lines
23 KiB
Python
475 lines
23 KiB
Python
"""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()
|