Files
Forge-Engine/tests/test_screens_and_elements.py
T
williamandClaude Sonnet 5 d875557254 Fusionne les 4 pages de création dans le dashboard, retire le titre/BDD
Suite de la demande précédente : les 4 pages autrefois listées dans la
barre de navigation n'existent plus en tant que pages séparées — tout
vit désormais dans les onglets du tableau de bord (commit précédent) ou,
pour la création d'un objet, dans un panneau déplaçable.

- screens_list.html + sa route (screens_list) : supprimés (l'onglet
  "Écrans du jeu" du dashboard couvre déjà tout : créer, réordonner,
  éditer, supprimer). screen_new/screen_move/screen_delete redirigent
  maintenant vers le dashboard (tab=screens) au lieu de cette page.
- element_types.html : supprimé, mais la route element_types est
  conservée (GET redirige vers le dashboard, POST — utilisé par la barre
  de création repliable de l'onglet "Éléments de jeu" — continue de
  fonctionner). element_type_edit/element_type_delete redirigent aussi
  vers le dashboard.
- game_variables.html + sa route (game_variables) : supprimés (l'onglet
  "Variables" du dashboard couvre déjà tout). create_global_var/
  global_var_edit/global_var_delete redirigent vers le dashboard
  (tab=variables) au lieu de cette page.
- object_form.html : supprimé. La route object_new (POST) est conservée
  pour traiter la soumission du panneau — voir plus bas — mais ne rend
  plus de page pour un GET (redirige vers le dashboard).

Nouveau panneau déplaçable "Nouvel objet" sur le dashboard (bouton
"+ Nouvel objet" de l'onglet Objets) : réutilise .floatPanel/
.floatPanelHeader/.floatPanelBody (déjà utilisées dans l'éditeur d'écran)
avec une nouvelle variante centrée (.floatPanel--center) et son propre
glisser-déposer minimal (pas de position persistée, contrairement aux
panneaux de l'éditeur d'écran — inutile pour un panneau ouvert
ponctuellement). Contenu et script (object_form.js) repris tels quels de
l'ancienne page.

base.html : la barre de navigation du jeu n'a donc plus que 2 liens —
"📊 Tableau de bord" (nouveau) et "▶️ Jouer" (toujours en dernier).

game_dashboard.html : titre du jeu et chemin de la base de données
retirés (redondants avec le nom déjà visible dans l'onglet du
navigateur/la barre de nav).

Les liens "crée-en un"/"gérer les variables" dans l'éditeur d'écran
(screen_edit.html) pointent maintenant vers le dashboard avec le bon
onglet (?tab=...), lu et appliqué au chargement de la page
(switchDashTab() côté JS).

2 tests (test_screens_and_elements.py) mis à jour : ils vérifiaient le
contenu des pages supprimées (element-types, screens) — adaptés pour
vérifier la même chose sur le dashboard, qui porte maintenant cette
information.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 10:54:19 +02:00

279 lines
14 KiB
Python

"""Tests des routes écrans / éléments posés sur un écran (via le client de
test Flask, sur une vraie base SQLite du jeu de test)."""
import re
def _create_screen(client, slug, name="Accueil"):
resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False)
assert resp.status_code == 302
return int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1))
def test_screen_crud(client, game):
screen_id = _create_screen(client, game)
resp = client.get(f"/game/{game}/screens/{screen_id}/edit")
assert resp.status_code == 200
assert b"Accueil" in resp.data
resp = client.post(f"/game/{game}/screens/{screen_id}/rename", data={"name": "Menu"}, follow_redirects=False)
assert resp.status_code == 302
resp = client.get(f"/game/{game}/screens/{screen_id}/edit")
assert b"Menu" in resp.data
resp = client.post(f"/game/{game}/screens/{screen_id}/delete", follow_redirects=False)
assert resp.status_code == 302
resp = client.get(f"/game/{game}/screens/{screen_id}/edit")
assert resp.status_code == 404
def test_add_element_and_save_properties(client, game):
screen_id = _create_screen(client, game)
resp = client.post(
f"/game/{game}/screens/{screen_id}/elements/add",
data={"widget": "titre"},
follow_redirects=False,
)
assert resp.status_code == 302
element_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
resp = client.post(
f"/game/{game}/elements/{element_id}/save",
data={"ctrl_content": "Bienvenue", "ctrl_niveau": "1"},
follow_redirects=False,
)
assert resp.status_code == 302
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
assert "Bienvenue" in html
def test_nested_child_carries_data_element_id_for_canvas_highlight(client, game):
"""Régression : le contour de sélection JS de l'éditeur (voir
applySelectionHighlight dans screen_edit.html) cible la balise de
l'élément via son attribut "data-element-id" — le seul repère possible
pour un enfant imbriqué, qui n'a pas de cadre ".canvasElement" séparé
comme un élément posé directement sur l'écran (voir visible_attrs.py)."""
screen_id = _create_screen(client, game)
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "conteneur"})
container_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
resp = client.post(f"/game/{game}/elements/{container_id}/children/add", data={"widget": "titre"})
child_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
assert f'data-element-id="{child_id}"' in html
def test_container_child_flex_row_no_collision(client, game):
"""Régression : un conteneur en disposition 'ligne' ne doit plus forcer
chacun de ses enfants à 100% de largeur (voir _style_string /
_parent_flex_direction) — sinon deux enfants entrent en collision."""
screen_id = _create_screen(client, game)
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "conteneur"})
container_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
client.post(
f"/game/{game}/elements/{container_id}/save",
data={"ctrl_disposition_interne": "ligne"},
)
resp = client.post(f"/game/{game}/elements/{container_id}/children/add", data={"widget": "titre"})
child1_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
resp = client.post(f"/game/{game}/elements/{container_id}/children/add", data={"widget": "texte"})
child2_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
idx1 = html.find(f'id="elt-{child1_id}"')
idx2 = html.find(f'id="elt-{child2_id}"')
style1 = html[max(0, idx1 - 400):idx1]
style2 = html[max(0, idx2 - 400):idx2]
# Aucun des deux enfants d'un conteneur en LIGNE ne doit avoir
# width:100% forcé (ce qui causait la collision avant correction).
assert "width:100%;" not in style1.split('style="')[-1] if 'style="' in style1 else True
assert "width:auto" in style1 or "width:auto" in style2
def test_element_type_template_case_a_and_case_b(client, game, mail_object):
slug, definition_id = mail_object["slug"], mail_object["definition_id"]
client.post(f"/game/{slug}/objects/{definition_id}/data/new", data={"sujet": "Sujet 1", "expediteur": "a@test.com"})
client.post(f"/game/{slug}/objects/{definition_id}/data/new", data={"sujet": "Sujet 2", "expediteur": "b@test.com"})
resp = client.post(
f"/game/{slug}/element-types",
data={"name": "CarteMail", "icon": "📧", "bound_definition_id": str(definition_id)},
follow_redirects=False,
)
assert resp.status_code == 302
html = client.get(f"/game/{slug}").data.decode()
m = re.search(r"/screens/(\d+)/edit", html)
assert m, "le lien vers l'écran-modèle doit apparaître dans le catalogue"
template_screen_id = int(m.group(1))
# L'écran-modèle est un écran caché (is_template) : absent de la liste
# normale des écrans jouables (onglet "Écrans du jeu" du tableau de
# bord) — son nom complet ("Modèle : CarteMail") ne doit donc apparaître
# nulle part sur cette page, contrairement à "CarteMail" seul (le nom de
# l'élément de jeu lui-même, affiché dans l'onglet "Éléments de jeu").
assert "Modèle : CarteMail" not in html
# Construit le contenu du modèle : un titre lié au champ "sujet".
resp = client.post(
f"/game/{slug}/screens/{template_screen_id}/elements/add",
data={"widget": "titre"},
)
titre_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
client.post(f"/game/{slug}/elements/{titre_id}/save", data={"ctrl_content": "{{sujet}}"})
element_type_id = int(re.search(r"element-types/(\d+)/delete", html).group(1))
# ---- Case A : posé directement sur un écran réel -> lié en direct au
# modèle (voir add_element.py/render_element_html.py), mais {{sujet}}
# reste non résolu hors Répéteur (pas de ligne de données courante) ----
real_screen_id = _create_screen(client, slug, "Accueil")
resp = client.post(
f"/game/{slug}/screens/{real_screen_id}/elements/add",
data={"widget": "__catalogue__", "element_type_id": str(element_type_id)},
follow_redirects=False,
)
assert resp.status_code == 302
html = client.get(f"/game/{slug}/screens/{real_screen_id}/edit").data.decode()
assert "{{sujet}}" in html # non résolu hors répéteur : limitation connue et documentée
# ---- Case B : modèle de ligne d'un Répéteur -> résolu en direct par ligne ----
resp = client.post(f"/game/{slug}/screens/{real_screen_id}/elements/add", data={"widget": "repeteur_donnees"})
rep_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
client.post(
f"/game/{slug}/elements/{rep_id}/save",
data={"ctrl_definition_id": str(definition_id), "ctrl_template_type_id": str(element_type_id)},
)
html = client.get(f"/game/{slug}/screens/{real_screen_id}/edit").data.decode()
assert "Sujet 1" in html
assert "Sujet 2" in html
def test_unrelated_property_save_does_not_bake_in_default_background(client, game):
"""Régression : changer un réglage (ex. la disposition interne) via le
panneau de propriétés ne doit PAS écrire la couleur de fond par défaut
du conteneur dans son style — cette valeur n'est qu'un aperçu affiché
dans le sélecteur de couleur tant qu'elle n'a pas été choisie
explicitement. Avant le correctif, comme tous les réglages du widget
sont envoyés dans UN SEUL formulaire, sauvegarder n'importe lequel
d'entre eux figeait aussi la couleur par défaut (#1f2430)."""
screen_id = _create_screen(client, game)
resp = client.post(
f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "conteneur"}, follow_redirects=False
)
container_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
html_before = client.get(f"/game/{game}/screens/{screen_id}/edit?selected={container_id}").data.decode()
assert "background-color" not in html_before
# On ne touche qu'au préréglage de disposition, rien d'autre.
client.post(
f"/game/{game}/elements/{container_id}/save",
data={"ctrl_disposition_interne": "ligne"},
follow_redirects=False,
)
html_after = client.get(f"/game/{game}/screens/{screen_id}/edit?selected={container_id}").data.decode()
assert "flex-direction:row" in html_after
assert "background-color" not in html_after, (
"La couleur de fond par défaut a été écrite alors qu'elle n'a jamais été choisie."
)
# Si on choisit vraiment une couleur, elle doit en revanche être conservée.
client.post(
f"/game/{game}/elements/{container_id}/save",
data={"ctrl_disposition_interne": "ligne", "ctrl_background": "#ff0000"},
follow_redirects=False,
)
html_custom = client.get(f"/game/{game}/screens/{screen_id}/edit?selected={container_id}").data.decode()
assert "background-color:#ff0000" in html_custom
def test_image_gets_object_fit_cover_by_default(client, game):
"""Régression : une image fraîchement posée doit démarrer avec
object-fit:cover (pour se recadrer proprement une fois redimensionnée)
même si le panneau de propriétés n'a jamais été enregistré — ce réglage
ne doit pas dépendre du même mécanisme de sauvegarde que la couleur de
fond (voir test ci-dessus), sous peine de régresser vers le
comportement par défaut du navigateur (image étirée/déformée)."""
screen_id = _create_screen(client, game)
resp = client.post(
f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "image"}, follow_redirects=False
)
assert resp.status_code == 302
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
assert "object-fit:cover" in html
def test_duplicate_top_level_element_creates_independent_copy(client, game):
screen_id = _create_screen(client, game)
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "titre"})
original_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
client.post(f"/game/{game}/elements/{original_id}/save", data={"ctrl_content": "Bienvenue"})
resp = client.post(f"/game/{game}/elements/{original_id}/duplicate", follow_redirects=False)
assert resp.status_code == 302
new_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
assert new_id != original_id
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
assert html.count("Bienvenue") == 2 # original + copie, chacun un élément séparé
# Modifier la copie ne doit pas toucher l'original (deux lignes indépendantes).
client.post(f"/game/{game}/elements/{new_id}/save", data={"ctrl_content": "Copie modifiée"})
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
assert "Bienvenue" in html
assert "Copie modifiée" in html
def test_duplicate_container_deep_copies_children(client, game):
"""Régression : dupliquer un conteneur doit copier tout son sous-arbre
(pas juste la boîte vide) — voir screens/elements/duplicate_element.py,
qui réutilise le même parcours récursif que la copie d'écran-modèle."""
screen_id = _create_screen(client, game)
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "conteneur"})
container_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
resp = client.post(f"/game/{game}/elements/{container_id}/children/add", data={"widget": "titre"})
child_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
client.post(f"/game/{game}/elements/{child_id}/save", data={"ctrl_content": "Titre enfant"})
resp = client.post(f"/game/{game}/elements/{container_id}/duplicate", follow_redirects=False)
new_container_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
assert new_container_id != container_id
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
assert html.count("Titre enfant") == 2 # l'enfant a bien été copié avec son parent
def test_duplicate_child_element_stays_in_same_container(client, game):
screen_id = _create_screen(client, game)
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "conteneur"})
container_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
resp = client.post(f"/game/{game}/elements/{container_id}/children/add", data={"widget": "texte"})
child_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
client.post(f"/game/{game}/elements/{child_id}/duplicate")
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
assert html.count("↳") == 0 # l'ancienne arborescence imbriquée dans "Ajouter DANS ce conteneur" a disparu
tree_html = html[html.find('class="elementTree"'):]
assert tree_html.count("treeChildren") >= 1 # le conteneur a bien 2 enfants imbriqués (le nouveau + l'original)
def test_screen_editor_property_panels_closed_by_default(client, game):
screen_id = _create_screen(client, game)
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "conteneur"})
container_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
html = client.get(f"/game/{game}/screens/{screen_id}/edit?selected={container_id}").data.decode()
assert "<details class=\"propGroup\" open>" not in html
assert "Un conteneur est sélectionné" not in html
def test_delete_element_type_removes_hidden_template_screen(client, game):
resp = client.post(f"/game/{game}/element-types", data={"name": "ToDelete", "icon": "🧩"}, follow_redirects=False)
html = client.get(f"/game/{game}").data.decode()
m = re.search(r"/screens/(\d+)/edit", html)
template_screen_id = int(m.group(1))
element_type_id = int(re.search(r"element-types/(\d+)/delete", html).group(1))
assert client.get(f"/game/{game}/screens/{template_screen_id}/edit").status_code == 200
client.post(f"/game/{game}/element-types/{element_type_id}/delete")
assert client.get(f"/game/{game}/screens/{template_screen_id}/edit").status_code == 404