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>
279 lines
14 KiB
Python
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
|