Files
Forge-Engine/tests/test_confort.py
T
williamandClaude Sonnet 5 6e46949cf8 Corrige la boîte de dialogue posée comme élément de jeu réutilisable : conteneur vide au lieu du dialogue
Bug rapporté : poser un élément de jeu ("Mes éléments de jeu") dont le
modèle n'est qu'une "Superposition / boîte de dialogue" sur une vraie
scène affichait un conteneur vide à l'endroit du dépôt — jamais le
dialogue. La boîte de dialogue existait bien (masquée comme prévu, en
attente d'une action qui l'affiche) : c'est l'enveloppe qui l'entoure qui
n'aurait jamais dû être visible.

Cause : tout exemplaire d'élément de jeu est posé avec le widget générique
"conteneur" par défaut (add_element.py, colonne default_widget) — son
contenu réel (le modèle) est rechargé EN DIRECT à l'intérieur
(_render_element_type_children), mais l'enveloppe "conteneur" elle-même
reste une boîte NORMALE, toujours visible, avec sa propre couleur de fond/
bordure et sa position fixe sur le canevas (contrairement à une
superposition posée directement, qui, elle, démarre masquée). Résultat :
une boîte vide et permanente à l'endroit du dépôt, pendant que le vrai
dialogue (démarré masqué, correctement) reste invisible en dessous/
au-dessus tant qu'aucune action ne le déclenche.

Correctif : quand le modèle ENTIER d'un élément de jeu n'est qu'une seule
superposition (screens/element_types/is_overlay_only.py, nouveau), on
court-circuite entièrement l'enveloppe "conteneur" (render_element_html.py)
et on retire aussi le z-index de son cadre de positionnement
(element_style_filter.py, list_elements.py) — même raison que pour une
superposition posée directement (81c31a9) : sans ça, ce cadre reste un
candidat à piéger le z-index:9999 du dialogue rendu à l'intérieur dès
qu'un autre élément de la scène a un z-index plus grand.

Nouveau test, confirmé en échec sur l'ancien code (même "class=\"box\""
fantôme reproduit) puis au vert avec le correctif. 140 tests au vert au
total.

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

380 lines
18 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_overlay_stays_visible_in_the_editor_despite_starting_masked(client, game):
"""Régression : une "superposition" démarre MASQUÉE par défaut à sa
création (display:none — voir default_style_for_widget.py, pour ne
pas couvrir tout l'écran dès qu'on la pose). Ce display:none est écrit
tel quel dans le HTML, aussi bien en mode jouable QUE dans l'éditeur —
sans forçage, la boîte de dialogue restait donc invisible dans le
CANEVAS DE L'ÉDITEUR dès l'instant où elle était créée, rendant
impossible d'y voir/positionner visuellement titres/textes/boutons
posés à l'intérieur ("j'ai mis un texte dedans mais il ne se voit pas"
— le texte était là, mais toute la boîte qui le contient était
display:none). Seul le mode JOUABLE doit respecter ce réglage."""
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"})
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
wrapper_style = re.search(r'style="([^"]*)"', edit_html[edit_html.rfind("<div", 0, idx):idx]).group(1)
# display:none PUIS display:flex (la dernière déclaration CSS gagne).
assert wrapper_style.rstrip().endswith("display:flex;")
play_html = client.get(f"/game/{game}/play").data.decode()
idx2 = play_html.find(f'id="elt-{overlay_id}"')
wrapper_style2 = re.search(r'style="([^"]*)"', play_html[play_html.rfind("<div", 0, idx2):idx2]).group(1)
# En mode jouable, pas de forçage : le display:none par défaut reste actif.
assert wrapper_style2.rstrip().endswith("display:none;")
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_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