A top-level element already got a selection border via its .canvasElement wrapper, but a child posed inside a container/répéteur/ groupe de champs has no such separate frame, so selecting it from the tree gave no visual feedback on the canvas at all. applySelectionHighlight() now targets the element's own tag directly via data-element-id (present on every rendered element, nested or not) and outlines it, re-run after every canvas refresh path (initial load, full pjax navigation, partial panel refresh, and the autosave-only canvas refresh). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
275 lines
14 KiB
Python
275 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}/element-types").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.
|
|
screens_html = client.get(f"/game/{slug}/screens").data.decode()
|
|
assert "CarteMail" not in screens_html or "Modèle" not in screens_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 -> copie indépendante ----
|
|
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}/element-types").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
|