Files
Forge-Engine/tests/test_confort.py
T
williamandClaude Sonnet 5 81c31a9c49 Corrige la boîte de dialogue (superposition) : un élément posé après elle s'affichait par-dessus
Bug visible sur le jeu de démo : le bouton "Clique-moi !" restait visible
ET cliquable AU-DESSUS du dialogue de bienvenue censé couvrir tout l'écran,
et la boîte de dialogue elle-même s'étirait bord à bord au lieu de rester
une boîte centrée lisible.

Cause (stacking context CSS) : le widget "superposition" ignore x/y/
width/height et pose lui-même position:fixed; inset:0; z-index:9999 sur
SA PROPRE balise (render_overlay.py) — mais le cadre .playElement/
.canvasElement qui l'entoure, PARTAGÉ PAR TOUS LES WIDGETS (filters/
element_style_filter.py), continuait quand même à poser
"position:absolute; z-index:<sa place dans le canevas>" (souvent petit,
ex. 1). 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 ajouté APRÈS l'overlay sur le canevas — qui
s'affichait donc par-dessus le dialogue.

Correctif : _element_style ne pose plus aucune position/z-index pour ce
widget (position:static — sa place dans le flux est de toute façon
invisible, son contenu réel étant en position:fixed). Plus de contexte
d'empilement local créé à ce niveau : le z-index:9999 se compare
directement à tous les autres éléments de l'écran, et gagne toujours.

Profité de l'occasion pour donner à la boîte une largeur par défaut plus
raisonnable (render_overlay.py : max-width:min(560px, 90%) au lieu de
90% seul) — sur un écran de jeu large, "90%" donnait une boîte étirée
bord à bord peu lisible comme dialogue ; 560px reste confortable, et 90%
prend toujours le relais sur un écran étroit (mobile/portrait).

Nouveau test de régression (test_overlay_wrapper_does_not_trap_its_own_z_index) :
confirmé en échec sur l'ancien code (git stash), au vert avec le
correctif. 129 tests au vert au total.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 18:43:29 +02:00

271 lines
12 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
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_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
"position:absolute; 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 ne doit donc
plus poser aucune position/z-index pour ce widget."""
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:static" in wrapper_tag
assert "position:absolute" not in wrapper_tag
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