Supprime le type d'écran "document" et recentre le produit sur le jeu 2D
Le document de cadrage produit cible des formateurs non techniques créant des serious games/quiz gamifiés — l'éditeur générique "document" (blocs de logique en nœuds, timeline d'animation, définitions d'objets/relations, templates réutilisables) est une complexité hors cible que l'effort d'ingénierie récent avait déjà abandonnée au profit du jeu_2d. - Onboarding : ne garde que le parcours "RPG" (jeu_2d), retire Quiz/Embranchement/Créer mon jeu de A à Z (tous document-only) - Suppression en bloc des modules exclusifs au document : routes/elements, routes/element_types, routes/objects, routes/legacy_actions, screens/elements, screens/element_types, screens/widgets, screens/legacy_actions, le rendu render_element_html.py et son cluster, templates/screen_edit.html, templates/game_dashboard.html, flow-editor.js/tabs-and-blocks.js/animation-timeline.js - Dashboard toujours simplifié (un seul mode possible désormais) - Tests document-only supprimés, tests de logique partagée (flow, événements personnalisés, animations) retargetés sur des écrans jeu_2d - Aucune régression jeu_2d : 299 tests passent Carte d'onboarding retravaillée : argumentaire RH non technique (liste à coche, badge "Compatible LMS"), taille et interaction de retournement ajustées.
This commit is contained in:
+6
-17
@@ -28,7 +28,7 @@ os.environ["FORGE_SECRET_KEY_PATH"] = os.path.join(_TEST_AUTH_DIR, "secret_key")
|
||||
import pytest
|
||||
|
||||
from core.flask_app import app as flask_app
|
||||
from core import jinja_filters # noqa: F401 - enregistre les filtres Jinja (elabel, elstyle, colname)
|
||||
from core import jinja_filters # noqa: F401 - enregistre les filtres Jinja (colname, truncatesentences)
|
||||
import routes # noqa: F401 - enregistre toutes les routes sur flask_app
|
||||
from core import auth_guard # noqa: F401 - enregistre la garde de connexion (après les routes)
|
||||
from core import csrf # noqa: F401 - enregistre csrf_token() comme variable globale Jinja
|
||||
@@ -159,19 +159,8 @@ def screen_edit_js_bundle():
|
||||
|
||||
@pytest.fixture
|
||||
def mail_object(client, game):
|
||||
"""Un objet "Mail" avec deux champs texte (sujet, expediteur), pour les
|
||||
tests de Répéteur / élément de jeu lié à un objet."""
|
||||
resp = client.post(
|
||||
f"/game/{game}/objects/new",
|
||||
data={
|
||||
"object_name": "Mail",
|
||||
"field_name[]": ["sujet", "expediteur"],
|
||||
"field_type[]": ["texte", "texte"],
|
||||
"field_relation[]": ["", ""],
|
||||
"field_required[]": ["0", "0"],
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert resp.status_code == 302
|
||||
definition_id = resp.headers["Location"].rstrip("/").split("/")[-1]
|
||||
return {"slug": game, "definition_id": int(definition_id)}
|
||||
"""Un objet "Mail" avec deux champs texte (sujet, expediteur)."""
|
||||
definition_id = db.create_definition(game, "Mail", [
|
||||
{"name": "sujet", "type": "texte"}, {"name": "expediteur", "type": "texte"},
|
||||
])
|
||||
return {"slug": game, "definition_id": definition_id}
|
||||
|
||||
@@ -26,19 +26,7 @@ def _add_flow_node(client, slug, screen_id, **fields):
|
||||
|
||||
|
||||
def _create_inventory_object(client, slug):
|
||||
resp = client.post(
|
||||
f"/game/{slug}/objects/new",
|
||||
data={
|
||||
"object_name": "Inventaire",
|
||||
"field_name[]": ["objet"],
|
||||
"field_type[]": ["texte"],
|
||||
"field_relation[]": [""],
|
||||
"field_required[]": ["0"],
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert resp.status_code == 302
|
||||
return int(resp.headers["Location"].rstrip("/").split("/")[-1])
|
||||
return db.create_definition(slug, "Inventaire", [{"name": "objet", "type": "texte"}])
|
||||
|
||||
|
||||
def test_run_add_row_route_creates_an_empty_row(client, game):
|
||||
|
||||
@@ -30,21 +30,6 @@ def test_sprite_gallery_families_shows_one_tile_per_animal():
|
||||
assert "animal-bunny-02" not in slugs
|
||||
|
||||
|
||||
def test_document_editor_hides_animal_characters_from_a_user_account(user_game, user_client):
|
||||
resp = user_client.post(f"/game/{user_game}/screens/new", data={"name": "Accueil"}, follow_redirects=False)
|
||||
screen_id = resp.headers["Location"].rstrip("/").split("/")[-2]
|
||||
html = user_client.get(f"/game/{user_game}/screens/{screen_id}/edit").get_data(as_text=True)
|
||||
assert "animal-bunny-01" not in html
|
||||
assert "male-adventurer" in html
|
||||
|
||||
|
||||
def test_document_editor_shows_animal_characters_to_admin(game, client):
|
||||
resp = client.post(f"/game/{game}/screens/new", data={"name": "Accueil"}, follow_redirects=False)
|
||||
screen_id = resp.headers["Location"].rstrip("/").split("/")[-2]
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit").get_data(as_text=True)
|
||||
assert "animal-bunny-01" in html
|
||||
|
||||
|
||||
def _create_jeu2d_game_as(post_client, name, slug_owner_client=None):
|
||||
# Fusion des moteurs (voir screens/screens_repo/ensure_schema.py) : un
|
||||
# jeu n'a plus de type propre — seul l'écran créé ensuite par chaque
|
||||
@@ -106,31 +91,6 @@ def test_scene_object_personnage_swap_rejects_animal_character_for_a_user_accoun
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_element_add_rejects_animal_character_for_a_user_account(user_game, user_client):
|
||||
resp = user_client.post(f"/game/{user_game}/screens/new", data={"name": "Accueil"}, follow_redirects=False)
|
||||
screen_id = resp.headers["Location"].rstrip("/").split("/")[-2]
|
||||
resp = user_client.post(
|
||||
f"/game/{user_game}/screens/{screen_id}/elements/add",
|
||||
data={"widget": "personnage", "forge_character": "animal-bunny-01"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_element_set_personnage_data_rejects_animal_character_for_a_user_account(user_game, user_client):
|
||||
resp = user_client.post(f"/game/{user_game}/screens/new", data={"name": "Accueil"}, follow_redirects=False)
|
||||
screen_id = resp.headers["Location"].rstrip("/").split("/")[-2]
|
||||
resp = user_client.post(
|
||||
f"/game/{user_game}/screens/{screen_id}/elements/add", data={"widget": "personnage"}, follow_redirects=False
|
||||
)
|
||||
element_id = int(resp.headers["Location"].rsplit("selected=", 1)[1])
|
||||
resp = user_client.post(
|
||||
f"/game/{user_game}/elements/{element_id}/personnage",
|
||||
data=json.dumps({"source": "forge", "forge_character": "animal-bunny-01"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_list_used_forge_characters_scans_elements_and_scene_objects(client, tmp_game_slug_cleanup):
|
||||
slug = tmp_game_slug_cleanup(_create_jeu2d_game_as(client, "pytest_used_characters"))
|
||||
screen_id = screens.create_screen(slug, "Scène 1")
|
||||
|
||||
+16
-12
@@ -4,6 +4,7 @@ et une durée sur l'axe du temps (secondes depuis l'affichage de l'écran)."""
|
||||
import json
|
||||
import re
|
||||
|
||||
import screens as screens_module
|
||||
from conftest import play_js_bundle
|
||||
|
||||
|
||||
@@ -12,14 +13,13 @@ def _create_screen(client, slug, name="Accueil"):
|
||||
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))
|
||||
def _add_element(client, slug, screen_id, widget=None):
|
||||
return screens_module.add_scene_object(slug, screen_id)
|
||||
|
||||
|
||||
def test_animate_css_clip_persists(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
el_id = _add_element(client, game, screen_id, "titre")
|
||||
el_id = _add_element(client, game, screen_id)
|
||||
resp = client.post(
|
||||
f"/game/{game}/screens/{screen_id}/animations/clips/add",
|
||||
data=json.dumps({
|
||||
@@ -39,7 +39,7 @@ def test_animate_css_clip_persists(client, game):
|
||||
|
||||
def test_custom_keyframes_clip_persists(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
el_id = _add_element(client, game, screen_id, "conteneur")
|
||||
el_id = _add_element(client, game, screen_id)
|
||||
keyframes = [{"percent": 0, "props": {"opacity": "0"}}, {"percent": 100, "props": {"opacity": "1"}}]
|
||||
resp = client.post(
|
||||
f"/game/{game}/screens/{screen_id}/animations/clips/add",
|
||||
@@ -57,7 +57,7 @@ def test_custom_keyframes_clip_persists(client, game):
|
||||
|
||||
def test_clip_update_changes_timing(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
el_id = _add_element(client, game, screen_id, "bouton")
|
||||
el_id = _add_element(client, game, screen_id)
|
||||
clip = client.post(
|
||||
f"/game/{game}/screens/{screen_id}/animations/clips/add",
|
||||
data=json.dumps({"element_id": el_id, "kind": "animate_css", "animate_name": "fadeIn"}),
|
||||
@@ -76,7 +76,7 @@ def test_clip_update_changes_timing(client, game):
|
||||
|
||||
def test_clip_delete_removes_it(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
el_id = _add_element(client, game, screen_id, "bouton")
|
||||
el_id = _add_element(client, game, screen_id)
|
||||
clip = client.post(
|
||||
f"/game/{game}/screens/{screen_id}/animations/clips/add",
|
||||
data=json.dumps({"element_id": el_id, "kind": "animate_css", "animate_name": "tada"}),
|
||||
@@ -84,15 +84,19 @@ def test_clip_delete_removes_it(client, game):
|
||||
).get_json()
|
||||
resp = client.post(f"/game/{game}/animations/clips/{clip['id']}/delete")
|
||||
assert resp.status_code == 200
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
|
||||
assert f'"id": {clip["id"]}' not in html
|
||||
import screens
|
||||
assert screens.get_animation_clip(game, clip["id"]) is None
|
||||
|
||||
|
||||
def test_screen_edit_exposes_timeline_panel_and_animate_css(client, game):
|
||||
def test_scene_editor_loads_animate_css(client, game):
|
||||
"""L'onglet dédié "Timeline d'animation" (animTabPanel) n'existe que
|
||||
dans l'éditeur document, retiré — l'éditeur de scène 2D n'a pas encore
|
||||
d'UI dédiée pour poser un clip (voir templates/scene_edit.html), mais
|
||||
charge quand même animate.min.css (consommé par le runtime jouable,
|
||||
voir test_play_page_exposes_animation_runtime_and_animate_css) et
|
||||
l'API de clips ci-dessus reste pleinement fonctionnelle."""
|
||||
screen_id = _create_screen(client, game)
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
|
||||
assert "animTabPanel" in html
|
||||
assert "Timeline d'animation" in html
|
||||
assert "animate.min.css" in html
|
||||
|
||||
|
||||
|
||||
+2
-3
@@ -45,12 +45,11 @@ def _confirm_2fa(client):
|
||||
return client.post("/register/2fa", data={"code": code}, follow_redirects=False), secret
|
||||
|
||||
|
||||
def _complete_onboarding(client, name="Mon jeu", onboarding_type="custom"):
|
||||
def _complete_onboarding(client, name="Mon jeu", onboarding_type="rpg"):
|
||||
"""Termine l'onboarding guidé (voir routes/onboarding/onboarding_new.py)
|
||||
— la plupart des tests de ce fichier n'ont pas besoin de tester
|
||||
l'onboarding lui-même (voir test_onboarding.py), juste d'obtenir un
|
||||
project_slug réel pour la suite ; "custom" est le choix le plus proche
|
||||
du comportement historique (tableau de bord complet)."""
|
||||
project_slug réel pour la suite ; "rpg" est le seul type restant."""
|
||||
return client.post(
|
||||
"/onboarding", data={"onboarding_type": onboarding_type, "name": name}, follow_redirects=False
|
||||
)
|
||||
|
||||
+10
-7
@@ -2,6 +2,8 @@
|
||||
import json
|
||||
import re
|
||||
|
||||
import db
|
||||
|
||||
|
||||
def _create_screen(client, slug, name="Accueil"):
|
||||
resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False)
|
||||
@@ -9,13 +11,14 @@ def _create_screen(client, slug, name="Accueil"):
|
||||
|
||||
|
||||
def _create_numeric_object(client, slug, field_name="reputation", min_v="0", max_v="100", start="50"):
|
||||
resp = client.post(f"/game/{slug}/objects/new", data={
|
||||
"object_name": "Partie", "field_name[]": [field_name], "field_type[]": ["nombre_entier"],
|
||||
"field_relation[]": [""], "field_required[]": ["0"],
|
||||
"field_min[]": [min_v], "field_max[]": [max_v],
|
||||
}, follow_redirects=False)
|
||||
def_id = int(resp.headers["Location"].rstrip("/").split("/")[-1])
|
||||
resp = client.post(f"/game/{slug}/objects/{def_id}/data/new", data={field_name: start})
|
||||
field = {"name": field_name, "type": "nombre_entier"}
|
||||
if min_v != "":
|
||||
field["min_value"] = min_v
|
||||
if max_v != "":
|
||||
field["max_value"] = max_v
|
||||
def_id = db.create_definition(slug, "Partie", [field])
|
||||
definition = db.get_definition(slug, def_id)
|
||||
db.insert_row(slug, definition, {field_name: start})
|
||||
return def_id
|
||||
|
||||
|
||||
|
||||
@@ -135,7 +135,9 @@ def test_full_game_payload_excludes_the_player_and_fond_from_collision_rule_obje
|
||||
jamais l'inverse — le joueur lui-même et un fond n'ont pas de champ
|
||||
"collision_rules" dans le payload."""
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
player_id = _add_personnage(client, slug, screen_id) # rôle par défaut : joueur
|
||||
# Rôle "pnj" par défaut (voir add_scene_object.py, demande explicite) :
|
||||
# "Joueur" est désormais toujours un choix explicite.
|
||||
player_id = _add_personnage(client, slug, screen_id, role="joueur")
|
||||
enemy_id = _add_personnage(client, slug, screen_id, role="ennemie")
|
||||
|
||||
payload = screens.full_game_payload(slug)
|
||||
@@ -148,7 +150,10 @@ def test_full_game_payload_excludes_the_player_and_fond_from_collision_rule_obje
|
||||
|
||||
def test_scene_edit_view_lists_eligible_objects_only(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
player_id = _add_personnage(client, slug, screen_id) # rôle par défaut : joueur, exclu
|
||||
# Rôle "pnj" par défaut (voir add_scene_object.py, demande explicite) :
|
||||
# "Joueur" (le seul rôle exclu de "🧩 Collision") est désormais toujours
|
||||
# un choix explicite.
|
||||
player_id = _add_personnage(client, slug, screen_id, role="joueur")
|
||||
enemy_id = _add_personnage(client, slug, screen_id, role="ennemie")
|
||||
|
||||
html = client.get(f"/game/{slug}/screens/{screen_id}/edit").get_data(as_text=True)
|
||||
|
||||
@@ -13,7 +13,7 @@ passent."""
|
||||
import json
|
||||
import re
|
||||
|
||||
from conftest import screen_edit_js_bundle
|
||||
import db
|
||||
|
||||
|
||||
def _create_screen(client, slug, name="Accueil"):
|
||||
@@ -46,11 +46,7 @@ def test_condition_node_stores_variable_source(client, game):
|
||||
|
||||
def test_condition_node_with_combined_variable_and_object_clauses(client, game):
|
||||
client.post(f"/game/{game}/variables/new", data={"name": "score", "type": "nombre_entier", "default_value": "5"})
|
||||
resp = client.post(f"/game/{game}/objects/new", data={
|
||||
"object_name": "Niveau", "field_name[]": ["numero"], "field_type[]": ["nombre_entier"],
|
||||
"field_relation[]": [""], "field_required[]": ["0"], "field_min[]": [""], "field_max[]": [""],
|
||||
}, follow_redirects=False)
|
||||
def_id = int(resp.headers["Location"].rstrip("/").split("/")[-1])
|
||||
def_id = db.create_definition(game, "Niveau", [{"name": "numero", "type": "nombre_entier"}])
|
||||
screen_id = _create_screen(client, game)
|
||||
|
||||
clauses = [
|
||||
@@ -81,26 +77,3 @@ def test_runtime_payload_exposes_global_variables_for_condition_evaluation(clien
|
||||
assert payload["variables"]["score"] == {"value": "5", "type": "nombre_entier"}
|
||||
assert payload["variables"]["perso"]["type"] == "objet"
|
||||
assert json.loads(payload["variables"]["perso"]["value"]) == {"nom": "Alice"}
|
||||
|
||||
|
||||
def test_screen_edit_panel_exposes_variable_source_condition_ui(client, game):
|
||||
client.post(f"/game/{game}/variables/new", data={"name": "score", "type": "nombre_entier", "default_value": "5"})
|
||||
screen_id = _create_screen(client, game)
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit").get_data(as_text=True)
|
||||
assert 'id="nf-cond-source"' in html
|
||||
assert 'id="nf-cond-variable-fields"' in html
|
||||
assert "onFlowConditionSourceChange" in html
|
||||
assert "onFlowClauseSourceChange" in screen_edit_js_bundle()
|
||||
|
||||
|
||||
def test_condition_builder_still_available_without_any_object_defined(client, game):
|
||||
"""Régression : le panneau de condition était entièrement masqué quand
|
||||
le jeu n'avait AUCUN objet ("Aucun objet défini..."), rendant
|
||||
impossible de créer une condition sur une variable dans ce cas. Seule
|
||||
la partie "Champ d'un objet" doit rester masquée ; "Variable globale"
|
||||
doit rester utilisable."""
|
||||
client.post(f"/game/{game}/variables/new", data={"name": "score", "type": "nombre_entier", "default_value": "5"})
|
||||
screen_id = _create_screen(client, game)
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit").get_data(as_text=True)
|
||||
assert 'id="nf-cond-source"' in html
|
||||
assert 'id="nf-cond-variable-fields"' in html
|
||||
|
||||
@@ -10,6 +10,8 @@ vérifient les tests de non-régression ci-dessous, en plus des tests dédiés
|
||||
import json
|
||||
import re
|
||||
|
||||
import db
|
||||
|
||||
from conftest import play_js_bundle
|
||||
|
||||
|
||||
@@ -20,17 +22,12 @@ def _create_screen(client, slug, name="Accueil"):
|
||||
|
||||
def _create_object_two_fields(client, slug):
|
||||
"""Crée un objet "Partie" avec deux champs numériques et une ligne."""
|
||||
resp = client.post(f"/game/{slug}/objects/new", data={
|
||||
"object_name": "Partie",
|
||||
"field_name[]": ["reputation", "niveau"],
|
||||
"field_type[]": ["nombre_entier", "nombre_entier"],
|
||||
"field_relation[]": ["", ""],
|
||||
"field_required[]": ["0", "0"],
|
||||
"field_min[]": ["", ""],
|
||||
"field_max[]": ["", ""],
|
||||
}, follow_redirects=False)
|
||||
def_id = int(resp.headers["Location"].rstrip("/").split("/")[-1])
|
||||
client.post(f"/game/{slug}/objects/{def_id}/data/new", data={"reputation": "5", "niveau": "3"})
|
||||
def_id = db.create_definition(slug, "Partie", [
|
||||
{"name": "reputation", "type": "nombre_entier"},
|
||||
{"name": "niveau", "type": "nombre_entier"},
|
||||
])
|
||||
definition = db.get_definition(slug, def_id)
|
||||
db.insert_row(slug, definition, {"reputation": "5", "niveau": "3"})
|
||||
return def_id
|
||||
|
||||
|
||||
|
||||
@@ -1,474 +0,0 @@
|
||||
"""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
|
||||
|
||||
from conftest import play_js_bundle
|
||||
|
||||
|
||||
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"."""
|
||||
js = play_js_bundle()
|
||||
assert "bindHoverTriggers" in js
|
||||
assert "'survol'" in js
|
||||
assert "'fin_survol'" in js
|
||||
|
||||
|
||||
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):
|
||||
assert "'contenu'" in play_js_bundle()
|
||||
|
||||
|
||||
# ---------- 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):
|
||||
js = play_js_bundle()
|
||||
assert "'attendre'" in js
|
||||
assert "setTimeout" in js
|
||||
|
||||
|
||||
# ---------- 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 play_js_bundle()
|
||||
|
||||
|
||||
# ---------- 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_visibility_dropdown_reflects_masque_even_when_visible_would_also_match(client, game):
|
||||
"""Régression (la vraie cause derrière "je ne vois plus ma modale, ni
|
||||
même en changeant Visibilité sur Visible") : le panneau de propriétés
|
||||
détecte la valeur ACTUELLE d'un réglage "preset" (screens/widgets/
|
||||
control_value.py) en cherchant la PREMIÈRE option de la liste dont les
|
||||
critères correspondent au style stocké. L'option "Visible normalement"
|
||||
de la Visibilité (visibility_control.py) ne vérifie QUE "visibility"
|
||||
(jamais "display", par choix assumé) — un élément "Masqué" (qui ne pose
|
||||
que "display:none", jamais "visibility") satisfaisait donc TOUJOURS,
|
||||
trivialement, les critères de "Visible" en premier (testée avant
|
||||
"Masqué" dans la liste) : le panneau affichait "Visible normalement"
|
||||
sélectionné sur un élément EN RÉALITÉ masqué. Comme un <select> ne
|
||||
déclenche un enregistrement que sur un changement RÉEL de valeur,
|
||||
re-choisir l'option déjà affichée ne faisait RIEN : impossible de
|
||||
rendre l'élément visible depuis le panneau. Corrigé en vérifiant les
|
||||
options les plus SPÉCIFIQUES (le plus de propriétés non vides exigées)
|
||||
en premier."""
|
||||
from screens.widgets.control_value import _control_value
|
||||
from screens.widgets.widget_meta import widget_meta
|
||||
import screens
|
||||
|
||||
screen_id = _create_screen(client, game)
|
||||
overlay_id = _add_element(client, game, screen_id, "superposition")
|
||||
el = screens.get_element(game, overlay_id)
|
||||
meta = widget_meta(el["widget"])
|
||||
vis_control = next(c for c in meta["controls"] if c["key"] == "visibilite")
|
||||
# "Masqué" par défaut à la création (default_style_for_widget.py) : le
|
||||
# panneau doit détecter "masque", pas "visible".
|
||||
assert _control_value(el, vis_control) == "masque"
|
||||
|
||||
|
||||
def test_overlay_behaves_like_a_normal_container_in_the_editor(client, game):
|
||||
"""Régression (deux retours utilisateur successifs) :
|
||||
1. Un essai précédent forçait "display:flex" dans l'éditeur, quel que
|
||||
soit le réglage "Visibilité" -> "je veux avoir la main sur la
|
||||
visibilité de la modale, sinon elle s'affiche toujours sur la
|
||||
scène [éditeur] et court-circuite ma logique". L'éditeur doit donc
|
||||
respecter "Visibilité" normalement, EXACTEMENT comme n'importe quel
|
||||
autre widget (masqué = display:none aussi dans l'éditeur).
|
||||
2. La boîte gardait son voile plein écran (position:fixed + fond
|
||||
assombri) même dans l'éditeur -> "je souhaite que rien ne soit
|
||||
assombri, l'assombrissement ne se fait que quand la scène est
|
||||
jouée". Le voile plein écran ne doit donc apparaître qu'en mode
|
||||
JOUABLE — dans l'éditeur, ce widget se comporte comme un conteneur
|
||||
normal (position/taille selon x/y/width/height, pas de voile)."""
|
||||
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"})
|
||||
|
||||
# Par défaut ("Masqué" à la création) : invisible dans l'éditeur aussi,
|
||||
# comme n'importe quel autre widget masqué (pas de forçage).
|
||||
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
|
||||
tag = edit_html[edit_html.rfind("<div", 0, idx):idx + 30]
|
||||
assert "display:none" in tag
|
||||
assert "position:fixed" not in tag
|
||||
assert "rgba(0,0,0,0.6)" not in tag
|
||||
|
||||
# Repassé "Visible" à la main (garder la main sur la visibilité) :
|
||||
# apparaît dans l'éditeur, mais toujours SANS voile plein écran.
|
||||
client.post(f"/game/{game}/elements/{overlay_id}/save", data={"ctrl_visibilite": "visible"})
|
||||
edit_html2 = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
|
||||
idx2 = edit_html2.find(f'id="elt-{overlay_id}"')
|
||||
tag2 = edit_html2[edit_html2.rfind("<div", 0, idx2):idx2 + 30]
|
||||
assert "display:none" not in tag2
|
||||
assert "position:fixed" not in tag2
|
||||
assert "rgba(0,0,0,0.6)" not in tag2
|
||||
|
||||
# En mode JOUABLE, le comportement plein écran + voile reste inchangé.
|
||||
play_html = client.get(f"/game/{game}/play").data.decode()
|
||||
idx3 = play_html.find(f'id="elt-{overlay_id}"')
|
||||
tag3 = play_html[play_html.rfind("<div", 0, idx3):idx3 + 250]
|
||||
assert "position:fixed" in tag3
|
||||
assert "rgba(0,0,0,0.6)" in tag3
|
||||
|
||||
|
||||
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_element_type_instance_is_controllable_from_the_hosting_scene(client, game):
|
||||
"""Régression : la logique posée sur LA SCÈNE qui accueille un
|
||||
exemplaire de dialogue (ex. "Modifier un élément → Modale : Visibilité
|
||||
= Rendre visible", ciblant l'exemplaire par son id SUR CETTE SCÈNE)
|
||||
n'avait plus aucun effet une fois l'enveloppe "conteneur" entièrement
|
||||
court-circuitée (un essai précédent) : son id disparaissait du DOM
|
||||
(impossible à cibler), et même en le gardant, la superposition INTERNE
|
||||
au modèle restait masquée indépendamment (double masquage — rendre
|
||||
l'enveloppe visible n'aurait rien changé). Corrigé : l'enveloppe
|
||||
GARDE son propre id/data-element-id (ciblable depuis la scène), et la
|
||||
superposition interne au modèle ignore désormais son propre réglage
|
||||
"Visibilité" une fois posée comme exemplaire — tout le masquage est
|
||||
délégué à l'enveloppe (qui démarre elle-même masquée par défaut, voir
|
||||
add_element.py)."""
|
||||
import screens
|
||||
resp = client.post(f"/game/{game}/element-types", data={"name": "Dialogue2"}, follow_redirects=False)
|
||||
et = next(t for t in screens.list_element_types(game) if t["name"] == "Dialogue2")
|
||||
overlay_id = _add_element(client, game, et["template_screen_id"], "superposition")
|
||||
client.post(f"/game/{game}/elements/{overlay_id}/children/add", data={"widget": "texte"})
|
||||
|
||||
screen_id = _create_screen(client, game, "Scène2")
|
||||
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add",
|
||||
data={"widget": "__catalogue__", "element_type_id": et["id"]}, follow_redirects=False)
|
||||
instance_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
|
||||
html = client.get(f"/game/{game}/play").data.decode()
|
||||
idx = html.find(f'data-element-id="{instance_id}"')
|
||||
assert idx != -1
|
||||
wrapper_tag = html[html.rfind("<div", 0, idx):idx + 40]
|
||||
assert "display:none" in wrapper_tag # masqué par défaut, sur l'EXEMPLAIRE
|
||||
|
||||
# La superposition interne au modèle, elle, ne doit PLUS porter son
|
||||
# propre display:none une fois rendue comme exemplaire (sinon la
|
||||
# rendre visible depuis la scène resterait sans effet).
|
||||
idx_modal = html.find('class="modal is-active"', idx)
|
||||
modal_tag = html[html.rfind("<div", 0, idx_modal):idx_modal + 250]
|
||||
assert "display:none" not in modal_tag
|
||||
|
||||
trig = client.post(f"/game/{game}/screens/{screen_id}/flow/nodes/add",
|
||||
data=json.dumps({"node_type": "trigger", "trigger_event": "affichage"}),
|
||||
content_type="application/json").get_json()
|
||||
act = client.post(f"/game/{game}/screens/{screen_id}/flow/nodes/add",
|
||||
data=json.dumps({
|
||||
"node_type": "action", "action_type": "modifier_element",
|
||||
"target_element_id": instance_id, "element_property": "visibilite", "element_value": "visible",
|
||||
}), content_type="application/json").get_json()
|
||||
assert act["target_element_id"] == instance_id
|
||||
|
||||
|
||||
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 play_js_bundle()
|
||||
@@ -87,14 +87,11 @@ def test_deleting_an_event_cascades_to_every_referencing_node_everywhere(client,
|
||||
node_type="trigger", trigger_event="evenement", trigger_custom_event_id=event_id,
|
||||
)
|
||||
|
||||
# Le modèle (élément de jeu réutilisable) est un écran caché — voir
|
||||
# screens.create_element_type — le déclencheur y est posé exactement
|
||||
# comme sur une vraie scène.
|
||||
screens.create_element_type(game, "Carte email", "📧")
|
||||
template = next(t for t in screens.list_element_types(game) if t["name"] == "Carte email")
|
||||
template_screen_id = template["template_screen_id"]
|
||||
# Une AUTRE scène — la cascade doit atteindre TOUS les écrans, pas
|
||||
# seulement celui qui a servi à déclencher la suppression.
|
||||
dispatcher_screen = _create_screen(client, game, "Écran déclencheur")
|
||||
dispatcher_node_id = _add_flow_node(
|
||||
client, game, template_screen_id,
|
||||
client, game, dispatcher_screen,
|
||||
node_type="action", action_type="declencher_evenement", target_custom_event_id=event_id,
|
||||
)
|
||||
|
||||
@@ -104,9 +101,9 @@ def test_deleting_an_event_cascades_to_every_referencing_node_everywhere(client,
|
||||
assert db.get_custom_event(game, event_id) is None
|
||||
assert screens.get_flow_node(game, listener_node_id) is None
|
||||
assert screens.get_flow_node(game, dispatcher_node_id) is None
|
||||
# Les écrans/modèles eux-mêmes ne sont pas touchés, seuls les nœuds.
|
||||
# Les écrans eux-mêmes ne sont pas touchés, seuls les nœuds.
|
||||
assert screens.get_screen(game, listener_screen) is not None
|
||||
assert screens.get_screen(game, template_screen_id) is not None
|
||||
assert screens.get_screen(game, dispatcher_screen) is not None
|
||||
|
||||
|
||||
def test_runtime_payload_exposes_both_nodes_for_the_client_js(client, game):
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
""""Donnée liée" (data_binding_controls.py) : liste de conditions
|
||||
ILLIMITÉE, combinées par ET ou par OU, plutôt que les 2 filtres fixes
|
||||
d'avant (toujours combinés en ET) — même principe que les conditions
|
||||
combinées du graphe de logique (cond_clauses/cond_combinator).
|
||||
Rétrocompatible avec les anciens éléments (_data_filtre_champ/
|
||||
_data_filtre2_champ), convertis à la volée en liste de clauses à la
|
||||
lecture (screens/clause_list_codec.py), sans migration de données."""
|
||||
import json
|
||||
import re
|
||||
|
||||
import db
|
||||
import screens
|
||||
|
||||
|
||||
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 _create_niveau_object(client, slug):
|
||||
resp = client.post(f"/game/{slug}/objects/new", data={
|
||||
"object_name": "Niveau", "field_name[]": ["numero", "parcours"],
|
||||
"field_type[]": ["nombre_entier", "texte"],
|
||||
"field_relation[]": ["", ""], "field_required[]": ["0", "0"],
|
||||
"field_min[]": ["", ""], "field_max[]": ["", ""],
|
||||
}, follow_redirects=False)
|
||||
def_id = int(resp.headers["Location"].rstrip("/").split("/")[-1])
|
||||
client.post(f"/game/{slug}/objects/{def_id}/data/new", data={"numero": "1", "parcours": "A"})
|
||||
client.post(f"/game/{slug}/objects/{def_id}/data/new", data={"numero": "2", "parcours": "A"})
|
||||
client.post(f"/game/{slug}/objects/{def_id}/data/new", data={"numero": "3", "parcours": "B"})
|
||||
return def_id
|
||||
|
||||
|
||||
def _rendered_text(client, slug):
|
||||
payload = client.get(f"/game/{slug}/runtime-payload").get_json()
|
||||
html = payload["screens"][0]["elements"][0]["rendered_html"]
|
||||
return re.sub(r"<[^>]+>", "", html)
|
||||
|
||||
|
||||
def test_three_clauses_combined_with_et(client, game):
|
||||
def_id = _create_niveau_object(client, game)
|
||||
screen_id = _create_screen(client, game)
|
||||
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "texte"}, follow_redirects=False)
|
||||
el_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
|
||||
client.post(f"/game/{game}/elements/{el_id}/save", data={
|
||||
"ctrl_content": "{{numero}}",
|
||||
"ctrl_data_definition_id": str(def_id),
|
||||
"ctrl_data_filtres_champ[]": ["parcours", "numero"],
|
||||
"ctrl_data_filtres_operateur[]": ["egal", "superieur"],
|
||||
"ctrl_data_filtres_valeur[]": ["A", "1"],
|
||||
"ctrl_data_filtres_combinateur": "et",
|
||||
})
|
||||
# parcours == A ET numero > 1 -> seule la ligne #2 correspond.
|
||||
assert _rendered_text(client, game) == "2"
|
||||
|
||||
|
||||
def test_clauses_combined_with_ou(client, game):
|
||||
def_id = _create_niveau_object(client, game)
|
||||
screen_id = _create_screen(client, game)
|
||||
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "texte"}, follow_redirects=False)
|
||||
el_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
|
||||
client.post(f"/game/{game}/elements/{el_id}/save", data={
|
||||
"ctrl_content": "{{numero}}",
|
||||
"ctrl_data_definition_id": str(def_id),
|
||||
"ctrl_data_filtres_champ[]": ["parcours", "numero"],
|
||||
"ctrl_data_filtres_operateur[]": ["egal", "egal"],
|
||||
"ctrl_data_filtres_valeur[]": ["B", "999"],
|
||||
"ctrl_data_filtres_combinateur": "ou",
|
||||
})
|
||||
# parcours == B OU numero == 999 -> seule la ligne #3 (parcours B) matche,
|
||||
# la plus récente d'abord (ORDER BY id DESC) donc c'est bien elle.
|
||||
assert _rendered_text(client, game) == "3"
|
||||
|
||||
|
||||
def test_clauses_stored_as_json_and_empty_rows_ignored(client, game):
|
||||
def_id = _create_niveau_object(client, game)
|
||||
screen_id = _create_screen(client, game)
|
||||
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "texte"}, follow_redirects=False)
|
||||
el_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
|
||||
client.post(f"/game/{game}/elements/{el_id}/save", data={
|
||||
"ctrl_data_definition_id": str(def_id),
|
||||
# Une ligne vide (ajoutée par erreur, jamais remplie) ne doit pas
|
||||
# compter comme une vraie condition.
|
||||
"ctrl_data_filtres_champ[]": ["numero", ""],
|
||||
"ctrl_data_filtres_operateur[]": ["egal", "egal"],
|
||||
"ctrl_data_filtres_valeur[]": ["2", ""],
|
||||
"ctrl_data_filtres_combinateur": "et",
|
||||
})
|
||||
el = screens.get_element(game, el_id)
|
||||
clauses = json.loads(el["attributes"]["_data_filtres_json"])
|
||||
assert clauses == [{"champ": "numero", "operateur": "egal", "valeur": "2"}]
|
||||
# combinateur "et" est la valeur par défaut : pas figé en base.
|
||||
assert "_data_filtre_combinateur" not in el["attributes"]
|
||||
|
||||
|
||||
def test_no_clause_falls_back_to_most_recent_row(client, game):
|
||||
def_id = _create_niveau_object(client, game)
|
||||
screen_id = _create_screen(client, game)
|
||||
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "texte"}, follow_redirects=False)
|
||||
el_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
|
||||
client.post(f"/game/{game}/elements/{el_id}/save", data={
|
||||
"ctrl_content": "{{numero}}", "ctrl_data_definition_id": str(def_id),
|
||||
})
|
||||
assert _rendered_text(client, game) == "3"
|
||||
|
||||
|
||||
def test_legacy_two_fixed_filters_still_resolve_via_fallback(client, game):
|
||||
"""Un élément créé AVANT cette fonctionnalité (attr:_data_filtre_champ/
|
||||
_data_filtre2_champ, jamais _data_filtres_json) doit continuer à
|
||||
fonctionner sans migration de données."""
|
||||
def_id = _create_niveau_object(client, game)
|
||||
screen_id = _create_screen(client, game)
|
||||
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "texte"}, follow_redirects=False)
|
||||
el_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
|
||||
conn = db.connect(game)
|
||||
conn.execute(
|
||||
"UPDATE _screen_elements SET attributes = ?, content = ? WHERE id = ?",
|
||||
(json.dumps({
|
||||
"_data_definition_id": str(def_id),
|
||||
"_data_filtre_champ": "parcours", "_data_filtre_operateur": "egal", "_data_filtre_valeur": "A",
|
||||
"_data_filtre2_champ": "numero", "_data_filtre2_operateur": "egal", "_data_filtre2_valeur": "2",
|
||||
}), "{{numero}}", el_id),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
# Ancien format : toujours combiné en ET -> parcours==A ET numero==2.
|
||||
assert _rendered_text(client, game) == "2"
|
||||
|
||||
# Le panneau reconstruit une clause éditable par ancien filtre non vide.
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit?selected={el_id}").get_data(as_text=True)
|
||||
assert 'name="ctrl_data_filtres_valeur[]" value="A"' in html
|
||||
assert 'name="ctrl_data_filtres_valeur[]" value="2"' in html
|
||||
|
||||
|
||||
def test_properties_panel_renders_scrollable_clause_list(client, game):
|
||||
def_id = _create_niveau_object(client, game)
|
||||
screen_id = _create_screen(client, game)
|
||||
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "texte"}, follow_redirects=False)
|
||||
el_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
client.post(f"/game/{game}/elements/{el_id}/save", data={"ctrl_data_definition_id": str(def_id)})
|
||||
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit?selected={el_id}").get_data(as_text=True)
|
||||
assert 'id="clauseListWrap-data_filtres"' in html
|
||||
assert "addClauseRow(" in html
|
||||
assert "clauseRowTemplate-data_filtres" in html
|
||||
@@ -1,100 +0,0 @@
|
||||
"""Régression : changer une variable globale (ou une donnée) pendant la
|
||||
partie recalculait bien, côté SERVEUR, quelle ligne "Donnée liée" (Texte/
|
||||
Titre) doit afficher — mais templates/play.html ne régénère, après une
|
||||
telle action, que les éléments dont le rendered_html porte un marqueur
|
||||
connu ("repeaterItem"/"jaugeBar"/"visibilityGated" — voir
|
||||
refreshRuntimeData()/hasMarker()). Un Texte/Titre "Donnée liée" n'en
|
||||
portait AUCUN : son contenu restait donc figé sur son ancienne ligne
|
||||
jusqu'au prochain rechargement complet de page, même si le bon contenu
|
||||
était déjà prêt côté serveur — "la variable change mais pas le contenu de
|
||||
la modale". Corrigé en ajoutant un marqueur "dataBound" (render_element_html.py),
|
||||
reconnu par hasMarker()."""
|
||||
import re
|
||||
|
||||
import screens
|
||||
|
||||
|
||||
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 test_data_bound_element_carries_the_refresh_marker(client, game):
|
||||
resp = client.post(f"/game/{game}/objects/new", data={
|
||||
"object_name": "Niveau", "field_name[]": ["numero", "spiker"],
|
||||
"field_type[]": ["nombre_entier", "texte"],
|
||||
"field_relation[]": ["", ""], "field_required[]": ["0", "0"],
|
||||
"field_min[]": ["", ""], "field_max[]": ["", ""],
|
||||
}, follow_redirects=False)
|
||||
def_id = int(resp.headers["Location"].rstrip("/").split("/")[-1])
|
||||
client.post(f"/game/{game}/objects/{def_id}/data/new", data={"numero": "1", "spiker": "un"})
|
||||
client.post(f"/game/{game}/objects/{def_id}/data/new", data={"numero": "2", "spiker": "deux"})
|
||||
client.post(f"/game/{game}/variables/new", data={"name": "n", "type": "nombre_entier", "default_value": "1"})
|
||||
|
||||
screen_id = _create_screen(client, game)
|
||||
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "texte"}, follow_redirects=False)
|
||||
el_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
client.post(f"/game/{game}/elements/{el_id}/save", data={
|
||||
"ctrl_content": "{{spiker}}",
|
||||
"ctrl_data_definition_id": str(def_id),
|
||||
"ctrl_data_filtres_champ[]": ["numero"],
|
||||
"ctrl_data_filtres_operateur[]": ["egal"],
|
||||
"ctrl_data_filtres_valeur[]": ["{{$n}}"],
|
||||
})
|
||||
|
||||
payload = client.get(f"/game/{game}/runtime-payload").get_json()
|
||||
el = payload["screens"][0]["elements"][0]
|
||||
assert "dataBound" in el["rendered_html"]
|
||||
assert "un" in el["rendered_html"]
|
||||
|
||||
# Change la variable -> le rendu recalculé (déjà correct côté serveur
|
||||
# avant ce correctif) doit refléter la nouvelle ligne.
|
||||
import db
|
||||
variable = db.get_global_variable(game, "n")
|
||||
db.update_global_variable(game, variable["id"], "nombre_entier", "2")
|
||||
|
||||
payload2 = client.get(f"/game/{game}/runtime-payload").get_json()
|
||||
el2 = payload2["screens"][0]["elements"][0]
|
||||
assert "deux" in el2["rendered_html"]
|
||||
assert "dataBound" in el2["rendered_html"]
|
||||
|
||||
|
||||
def test_element_type_instance_bubbles_up_the_data_bound_marker(client, game):
|
||||
"""Même vérification, mais pour un Texte "Donnée liée" posé DANS un
|
||||
élément de jeu réutilisable : le marqueur porté par l'élément interne
|
||||
doit se retrouver dans le rendered_html de l'EXEMPLAIRE (le seul élément
|
||||
réellement présent dans la liste plate de la scène qui l'accueille —
|
||||
voir list_elements.py, les descendants d'un exemplaire sont exclus)."""
|
||||
resp = client.post(f"/game/{game}/objects/new", data={
|
||||
"object_name": "dialog", "field_name[]": ["order", "spiker"],
|
||||
"field_type[]": ["nombre_entier", "texte"],
|
||||
"field_relation[]": ["", ""], "field_required[]": ["0", "0"],
|
||||
"field_min[]": ["", ""], "field_max[]": ["", ""],
|
||||
}, follow_redirects=False)
|
||||
def_id = int(resp.headers["Location"].rstrip("/").split("/")[-1])
|
||||
client.post(f"/game/{game}/objects/{def_id}/data/new", data={"order": "1", "spiker": "Un"})
|
||||
client.post(f"/game/{game}/objects/{def_id}/data/new", data={"order": "2", "spiker": "Deux"})
|
||||
client.post(f"/game/{game}/variables/new", data={"name": "dialog_order", "type": "nombre_entier", "default_value": "1"})
|
||||
|
||||
client.post(f"/game/{game}/element-types", data={"name": "dialog"}, follow_redirects=False)
|
||||
et = next(t for t in screens.list_element_types(game) if t["name"] == "dialog")
|
||||
resp = client.post(f"/game/{game}/screens/{et['template_screen_id']}/elements/add", data={"widget": "superposition"}, follow_redirects=False)
|
||||
overlay_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
resp = client.post(f"/game/{game}/elements/{overlay_id}/children/add", data={"widget": "texte"}, follow_redirects=False)
|
||||
spiker_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
client.post(f"/game/{game}/elements/{spiker_id}/save", data={
|
||||
"ctrl_content": "{{spiker}}",
|
||||
"ctrl_data_definition_id": str(def_id),
|
||||
"ctrl_data_filtres_champ[]": ["order"],
|
||||
"ctrl_data_filtres_operateur[]": ["egal"],
|
||||
"ctrl_data_filtres_valeur[]": ["{{$dialog_order}}"],
|
||||
})
|
||||
|
||||
screen_id = _create_screen(client, game, "Scene")
|
||||
client.post(f"/game/{game}/screens/{screen_id}/elements/add",
|
||||
data={"widget": "__catalogue__", "element_type_id": et["id"]}, follow_redirects=False)
|
||||
|
||||
payload = client.get(f"/game/{game}/runtime-payload").get_json()
|
||||
instance = payload["screens"][0]["elements"][0]
|
||||
assert "dataBound" in instance["rendered_html"]
|
||||
assert "Un" in instance["rendered_html"]
|
||||
@@ -1,42 +0,0 @@
|
||||
"""Régression : chaque objet a sa propre table SQLite, donc ses ids de
|
||||
ligne repartent de 1 (db/rows/insert_row.py) — les <form id="dataEditForm
|
||||
{{r.id}}"> de l'onglet Données (game_dashboard.html) doivent donc être
|
||||
scopés par objet (dataEditForm{{d.id}}-{{r.id}}), sinon deux objets ayant
|
||||
chacun une ligne #1 partagent le même id de formulaire dans le DOM et le
|
||||
navigateur soumet vers le premier objet trouvé, pas celui qu'on modifie
|
||||
réellement."""
|
||||
import re
|
||||
|
||||
|
||||
def _create_object(client, slug, name, field_name, field_type="texte"):
|
||||
resp = client.post(f"/game/{slug}/objects/new", data={
|
||||
"object_name": name, "field_name[]": [field_name], "field_type[]": [field_type],
|
||||
"field_relation[]": [""], "field_required[]": ["0"], "field_min[]": [""], "field_max[]": [""],
|
||||
}, follow_redirects=False)
|
||||
return int(resp.headers["Location"].rstrip("/").split("/")[-1])
|
||||
|
||||
|
||||
def test_data_edit_form_ids_are_scoped_per_object(client, game):
|
||||
def_a = _create_object(client, game, "Jauge", "valeur", "nombre_entier")
|
||||
def_b = _create_object(client, game, "Autre", "nom", "texte")
|
||||
|
||||
# Chaque objet a sa PROPRE table -> sa première ligne a id=1 dans les
|
||||
# deux cas, exactement le scénario qui provoquait la collision.
|
||||
client.post(f"/game/{game}/objects/{def_a}/data/new", data={"valeur": "10"})
|
||||
client.post(f"/game/{game}/objects/{def_b}/data/new", data={"nom": "Bob"})
|
||||
|
||||
html = client.get(f"/game/{game}?tab=objects").get_data(as_text=True)
|
||||
assert f'id="dataEditForm{def_a}-1"' in html
|
||||
assert f'id="dataEditForm{def_b}-1"' in html
|
||||
# Les deux formulaires doivent avoir des ids DISTINCTS malgré le même row id.
|
||||
assert html.count('id="dataEditForm') == len(set(re.findall(r'id="(dataEditForm[^"]+)"', html)))
|
||||
|
||||
# Modifier la donnée du DEUXIÈME objet doit bien mettre à jour def_b,
|
||||
# sans toucher def_a.
|
||||
client.post(f"/game/{game}/objects/{def_b}/data/1/edit", data={"nom": "Alice"})
|
||||
|
||||
import db
|
||||
row_a = db.list_rows(game, db.get_definition(game, def_a))[0]
|
||||
row_b = db.list_rows(game, db.get_definition(game, def_b))[0]
|
||||
assert row_a["valeur"] == 10
|
||||
assert row_b["nom"] == "Alice"
|
||||
@@ -1,104 +0,0 @@
|
||||
"""Régression : supprimer un élément référencé par un nœud de la Logique
|
||||
de la scène (comme déclencheur "clic sur cet élément", ou comme cible
|
||||
d'une action "Modifier cet élément"/"Activer cet onglet") plantait avec
|
||||
sqlite3.IntegrityError: FOREIGN KEY constraint failed — trigger_element_id
|
||||
et target_element_id (voir screens/flow/ensure_flow_schema.py) n'ont pas
|
||||
de ON DELETE CASCADE."""
|
||||
import json
|
||||
import re
|
||||
|
||||
import screens
|
||||
|
||||
|
||||
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="conteneur"):
|
||||
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))
|
||||
|
||||
|
||||
def test_delete_impact_reports_zero_when_unused_by_flow(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
element_id = _add_element(client, game, screen_id, "bouton")
|
||||
resp = client.get(f"/game/{game}/elements/{element_id}/delete-impact")
|
||||
assert resp.get_json() == {"flow_node_count": 0}
|
||||
|
||||
|
||||
def test_delete_impact_reports_referencing_flow_nodes_before_deletion(client, game):
|
||||
"""La modale de confirmation (screen_edit.html) interroge cette route
|
||||
AVANT que l'utilisateur ne confirme, pour l'avertir que des nœuds de
|
||||
la Logique de la scène seraient aussi supprimés — sans jamais rien
|
||||
supprimer elle-même."""
|
||||
screen_id = _create_screen(client, game)
|
||||
element_id = _add_element(client, game, screen_id, "bouton")
|
||||
client.post(
|
||||
f"/game/{game}/screens/{screen_id}/flow/nodes/add",
|
||||
data=json.dumps({"node_type": "trigger", "trigger_event": "clic", "trigger_element_id": element_id}),
|
||||
content_type="application/json",
|
||||
)
|
||||
resp = client.get(f"/game/{game}/elements/{element_id}/delete-impact")
|
||||
assert resp.get_json() == {"flow_node_count": 1}
|
||||
# Rien n'a été supprimé par cette simple consultation.
|
||||
assert screens.get_element(game, element_id) is not None
|
||||
|
||||
|
||||
def test_deleting_an_element_used_as_a_flow_trigger_does_not_crash(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
element_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": "trigger", "trigger_event": "clic", "trigger_element_id": element_id}),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
resp = client.post(f"/game/{game}/elements/{element_id}/delete", follow_redirects=False)
|
||||
assert resp.status_code == 302
|
||||
assert screens.get_element(game, element_id) is None
|
||||
|
||||
|
||||
def test_deleting_an_element_used_as_a_flow_action_target_does_not_crash(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
other_element_id = _add_element(client, game, screen_id, "bouton")
|
||||
target_element_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_element_id,
|
||||
"element_property": "visibilite", "element_value": "cache",
|
||||
}),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
resp = client.post(f"/game/{game}/elements/{target_element_id}/delete", follow_redirects=False)
|
||||
assert resp.status_code == 302
|
||||
assert screens.get_element(game, target_element_id) is None
|
||||
# L'autre élément (non concerné) reste intact.
|
||||
assert screens.get_element(game, other_element_id) is not None
|
||||
|
||||
|
||||
def test_deleting_a_container_cleans_up_flow_references_to_its_descendants(client, game):
|
||||
"""Un conteneur supprimé cascade la suppression de ses enfants au
|
||||
niveau SQL (parent_id ON DELETE CASCADE) - sans passer par
|
||||
delete_element.py pour chacun d'eux. Le nettoyage doit donc couvrir
|
||||
aussi les DESCENDANTS, pas seulement l'élément explicitement supprimé."""
|
||||
screen_id = _create_screen(client, game)
|
||||
container_id = _add_element(client, game, screen_id, "conteneur")
|
||||
resp = client.post(f"/game/{game}/elements/{container_id}/children/add", data={"widget": "bouton"}, follow_redirects=False)
|
||||
child_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
|
||||
resp = client.post(
|
||||
f"/game/{game}/screens/{screen_id}/flow/nodes/add",
|
||||
data=json.dumps({"node_type": "trigger", "trigger_event": "clic", "trigger_element_id": child_id}),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
resp = client.post(f"/game/{game}/elements/{container_id}/delete", follow_redirects=False)
|
||||
assert resp.status_code == 302
|
||||
assert screens.get_element(game, container_id) is None
|
||||
assert screens.get_element(game, child_id) is None
|
||||
@@ -1,76 +0,0 @@
|
||||
"""Sélecteur de champ du panneau de propriétés ("+ Ajouter" à côté de
|
||||
"Contenu", voir insertFieldAtCursor() dans screen_edit.html) : doit
|
||||
apparaître dès qu'un objet est disponible dans le contexte, pas seulement
|
||||
quand le widget a lui-même une "Donnée liée" réglée."""
|
||||
import re
|
||||
|
||||
import screens
|
||||
|
||||
|
||||
def _create_email_object(client, slug):
|
||||
resp = client.post(f"/game/{slug}/objects/new", data={
|
||||
"object_name": "Email", "field_name[]": ["sujet"], "field_type[]": ["texte"],
|
||||
"field_relation[]": [""], "field_required[]": ["0"], "field_min[]": [""], "field_max[]": [""],
|
||||
}, follow_redirects=False)
|
||||
return int(resp.headers["Location"].rstrip("/").split("/")[-1])
|
||||
|
||||
|
||||
def test_new_widget_inside_a_bound_element_type_gets_the_field_picker_without_extra_setup(client, game):
|
||||
"""Régression : une "Mail card" (élément de jeu créé avec "Objet lié" =
|
||||
Email) doit proposer le sélecteur de champ sur tout nouveau Titre/Texte
|
||||
posé dans son écran-modèle, sans qu'il faille en plus régler "Lier à un
|
||||
objet de données" sur CE widget précis — l'objet lié à l'élément de jeu
|
||||
suffit."""
|
||||
def_id = _create_email_object(client, game)
|
||||
resp = client.post(f"/game/{game}/element-types", data={
|
||||
"name": "Mail card", "icon": "📧", "bound_definition_id": str(def_id),
|
||||
}, follow_redirects=False)
|
||||
assert resp.status_code == 302
|
||||
et = next(t for t in screens.list_element_types(game) if t["name"] == "Mail card")
|
||||
template_screen_id = et["template_screen_id"]
|
||||
|
||||
resp = client.post(f"/game/{game}/screens/{template_screen_id}/elements/add", data={"widget": "titre"}, follow_redirects=False)
|
||||
element_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
|
||||
html = client.get(f"/game/{game}/screens/{template_screen_id}/edit?selected={element_id}").data.decode()
|
||||
assert 'id="fieldPicker-content"' in html
|
||||
assert '<option value="sujet">' in html
|
||||
|
||||
|
||||
def test_field_picker_absent_when_element_type_has_no_bound_object(client, game):
|
||||
resp = client.post(f"/game/{game}/element-types", data={"name": "Carte simple", "icon": "🃏"}, follow_redirects=False)
|
||||
assert resp.status_code == 302
|
||||
et = next(t for t in screens.list_element_types(game) if t["name"] == "Carte simple")
|
||||
template_screen_id = et["template_screen_id"]
|
||||
|
||||
resp = client.post(f"/game/{game}/screens/{template_screen_id}/elements/add", data={"widget": "titre"}, follow_redirects=False)
|
||||
element_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
|
||||
html = client.get(f"/game/{game}/screens/{template_screen_id}/edit?selected={element_id}").data.decode()
|
||||
assert 'id="fieldPicker-content"' not in html
|
||||
|
||||
|
||||
def test_widget_own_data_binding_still_takes_priority_over_element_type_bound_object(client, game):
|
||||
"""Non-régression : si le widget a SA PROPRE "Donnée liée" réglée (sur
|
||||
un objet différent de celui de l'élément de jeu), c'est elle qui doit
|
||||
fournir les champs du sélecteur, pas l'objet lié de l'élément de jeu."""
|
||||
email_id = _create_email_object(client, game)
|
||||
resp = client.post(f"/game/{game}/objects/new", data={
|
||||
"object_name": "Partie", "field_name[]": ["score"], "field_type[]": ["nombre_entier"],
|
||||
"field_relation[]": [""], "field_required[]": ["0"], "field_min[]": [""], "field_max[]": [""],
|
||||
}, follow_redirects=False)
|
||||
partie_id = int(resp.headers["Location"].rstrip("/").split("/")[-1])
|
||||
|
||||
resp = client.post(f"/game/{game}/element-types", data={
|
||||
"name": "Mail card 2", "icon": "📧", "bound_definition_id": str(email_id),
|
||||
}, follow_redirects=False)
|
||||
et = next(t for t in screens.list_element_types(game) if t["name"] == "Mail card 2")
|
||||
template_screen_id = et["template_screen_id"]
|
||||
|
||||
resp = client.post(f"/game/{game}/screens/{template_screen_id}/elements/add", data={"widget": "titre"}, follow_redirects=False)
|
||||
element_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
client.post(f"/game/{game}/elements/{element_id}/save", data={"ctrl_data_definition_id": str(partie_id)})
|
||||
|
||||
html = client.get(f"/game/{game}/screens/{template_screen_id}/edit?selected={element_id}").data.decode()
|
||||
assert '<option value="score">' in html
|
||||
assert '<option value="sujet">' not in html
|
||||
@@ -1,121 +0,0 @@
|
||||
"""Un exemplaire d'élément de jeu posé directement sur un écran (catalogue)
|
||||
est désormais TOUJOURS lié à son modèle, comme un modèle de ligne de
|
||||
Répéteur (voir add_element.py, render_element_html.py) : modifier
|
||||
l'élément de jeu dans son propre éditeur met à jour tous ses exemplaires
|
||||
déjà posés, sans avoir à les retoucher un par un — au prix de ne plus
|
||||
pouvoir personnaliser individuellement le contenu d'un exemplaire précis."""
|
||||
import re
|
||||
|
||||
import screens
|
||||
|
||||
|
||||
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 _create_card_element_type(client, slug, name="Carte"):
|
||||
resp = client.post(f"/game/{slug}/element-types", data={"name": name, "icon": "🃏"}, follow_redirects=False)
|
||||
assert resp.status_code == 302
|
||||
et = next(t for t in screens.list_element_types(slug) if t["name"] == name)
|
||||
resp = client.post(f"/game/{slug}/screens/{et['template_screen_id']}/elements/add", data={"widget": "titre"}, follow_redirects=False)
|
||||
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": "Version 1"})
|
||||
return et, titre_id
|
||||
|
||||
|
||||
def test_editing_the_template_updates_an_already_placed_instance(client, game):
|
||||
et, titre_id = _create_card_element_type(client, game)
|
||||
screen_id = _create_screen(client, game)
|
||||
client.post(
|
||||
f"/game/{game}/screens/{screen_id}/elements/add",
|
||||
data={"widget": "__catalogue__", "element_type_id": str(et["id"])},
|
||||
follow_redirects=False,
|
||||
)
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
|
||||
assert "Version 1" in html
|
||||
|
||||
client.post(f"/game/{game}/elements/{titre_id}/save", data={"ctrl_content": "Version 2"})
|
||||
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
|
||||
assert "Version 2" in html
|
||||
assert "Version 1" not in html
|
||||
|
||||
|
||||
def test_editing_the_template_updates_the_play_page_too(client, game):
|
||||
et, titre_id = _create_card_element_type(client, game)
|
||||
screen_id = _create_screen(client, game)
|
||||
client.post(
|
||||
f"/game/{game}/screens/{screen_id}/elements/add",
|
||||
data={"widget": "__catalogue__", "element_type_id": str(et["id"])},
|
||||
follow_redirects=False,
|
||||
)
|
||||
client.post(f"/game/{game}/elements/{titre_id}/save", data={"ctrl_content": "Contenu mis à jour"})
|
||||
|
||||
html = client.get(f"/game/{game}/play").data.decode()
|
||||
assert "Contenu mis à jour" in html
|
||||
|
||||
|
||||
def test_leftover_orphaned_children_no_longer_appear_in_the_editor(client, game):
|
||||
"""Régression : un exemplaire posé AVANT ce mécanisme (ou tout ancien
|
||||
enfant copié en base par l'ex-instantiate_template_tree, retiré)
|
||||
laisse d'éventuelles lignes orphelines en base sous l'exemplaire — leur
|
||||
contenu n'est plus jamais rendu (voir render_element_html.py), mais
|
||||
elles restaient sélectionnables dans l'arborescence de l'éditeur, avec
|
||||
leurs propres réglages jamais synchronisés avec le modèle : une taille
|
||||
de texte modifiée dans le modèle ne s'y reflétait jamais, donnant
|
||||
l'impression trompeuse que "les propriétés ne sont pas prises en
|
||||
compte"."""
|
||||
import json
|
||||
|
||||
import db
|
||||
|
||||
et, _titre_id = _create_card_element_type(client, game)
|
||||
screen_id = _create_screen(client, game)
|
||||
resp = client.post(
|
||||
f"/game/{game}/screens/{screen_id}/elements/add",
|
||||
data={"widget": "__catalogue__", "element_type_id": str(et["id"])},
|
||||
follow_redirects=False,
|
||||
)
|
||||
instance_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
|
||||
conn = db.connect(game)
|
||||
conn.execute(
|
||||
"""INSERT INTO _screen_elements (screen_id, parent_id, widget, content, attributes, x, y, width, height, z_index, style)
|
||||
VALUES (?, ?, 'titre', 'Ancien contenu figé', '{}', 10, 10, 50, 20, 1, ?)""",
|
||||
(screen_id, instance_id, json.dumps({"font-size": "21px"})),
|
||||
)
|
||||
orphan_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
|
||||
assert f'id="elt-{orphan_id}"' not in html
|
||||
assert "Ancien contenu figé" not in html
|
||||
|
||||
html_selected = client.get(f"/game/{game}/screens/{screen_id}/edit?selected={orphan_id}").data.decode()
|
||||
assert 'value="21' not in html_selected
|
||||
|
||||
|
||||
def test_instance_position_on_screen_is_independent_per_instance(client, game):
|
||||
"""Non-régression : la position/taille sur l'écran reste propre à
|
||||
chaque exemplaire (seul le CONTENU intérieur est désormais partagé)."""
|
||||
et, _titre_id = _create_card_element_type(client, game)
|
||||
screen_id = _create_screen(client, game)
|
||||
|
||||
resp = client.post(
|
||||
f"/game/{game}/screens/{screen_id}/elements/add",
|
||||
data={"widget": "__catalogue__", "element_type_id": str(et["id"])},
|
||||
follow_redirects=False,
|
||||
)
|
||||
first_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
resp = client.post(
|
||||
f"/game/{game}/screens/{screen_id}/elements/add",
|
||||
data={"widget": "__catalogue__", "element_type_id": str(et["id"])},
|
||||
follow_redirects=False,
|
||||
)
|
||||
second_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
|
||||
first = screens.get_element(game, first_id)
|
||||
second = screens.get_element(game, second_id)
|
||||
assert (first["x"], first["y"]) != (second["x"], second["y"])
|
||||
@@ -50,110 +50,6 @@ def test_export_scorm_index_html_is_offline_and_self_contained(client, game):
|
||||
assert "let gameData = " in html
|
||||
|
||||
|
||||
def test_export_scorm_icon_widget_is_inlined_as_a_data_uri(client, game):
|
||||
"""Régression signalée par un utilisateur (trois fois de suite) :
|
||||
render_icone.py construit son URL en dur avec un préfixe "/static/..."
|
||||
(correct EN LIGNE, où il part de la racine du serveur Flask). Cette
|
||||
URL, posée en style INLINE (--icon-url), est CONSOMMÉE par `mask-image:
|
||||
var(--icon-url)` dans static/style.css (voir .icon-svg) :
|
||||
- un premier correctif ("static/icons/x.svg") ignorait qu'une URL
|
||||
relative dans une propriété personnalisée CSS se résout par rapport
|
||||
à LA FEUILLE DE STYLE où le var() est utilisé (static/style.css),
|
||||
pas où elle est définie (piège CSS connu) — devenait donc
|
||||
"static/static/icons/x.svg" ;
|
||||
- un second correctif ("icons/x.svg", sans "static/") réglait bien CE
|
||||
chemin, mais Firefox bloque encore par CORS TOUT chemin de fichier
|
||||
pour mask-image sous file:// (chaque ressource file:// y est une
|
||||
origine opaque distincte, indépendamment du chemin).
|
||||
Seule une data: URI (aucune requête réseau séparée) contourne
|
||||
entièrement le problème — voir _build_icon_data_uris
|
||||
(publish/build_scorm_package.py) et son miroir JS (forgeRenderIcone,
|
||||
static/js/play/offline/render-special-widgets.js, qui régénère ce
|
||||
même widget après une action et devait être corrigé séparément)."""
|
||||
import re
|
||||
resp = client.post(f"/game/{game}/screens/new", data={"name": "Accueil"}, follow_redirects=False)
|
||||
screen_id = int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1))
|
||||
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "icone"}, follow_redirects=False)
|
||||
assert resp.status_code == 302, resp.data
|
||||
|
||||
resp = client.post(f"/game/{game}/export-scorm")
|
||||
import io
|
||||
with zipfile.ZipFile(io.BytesIO(resp.data)) as zf:
|
||||
html = zf.read("index.html").decode("utf-8")
|
||||
assert "--icon-url:url('data:image/svg+xml;base64," in html
|
||||
assert "icons/star.svg" not in html
|
||||
assert '"icon_data_uris": {"star":' in html
|
||||
|
||||
|
||||
def test_export_scorm_databound_text_resolves_a_relation_field(client, game):
|
||||
"""Régression signalée par un utilisateur : un Texte "Donnée liée" (ou
|
||||
un filtre de Répéteur) referençant un champ "relation" affichait
|
||||
"{{champ}}" tel quel une fois exporté, alors qu'il fonctionnait très
|
||||
bien EN LIGNE. Cause racine : full_game_payload.py construisait
|
||||
gameData.data en lisant la colonne SQL "<champ>" au lieu de
|
||||
"<champ>_id" (seule vraie colonne d'un champ relation, voir
|
||||
_field_column dans filter_repeater_rows.py) — une valeur toujours None
|
||||
qu'EN LIGNE personne ne remarquait puisque filter_repeater_rows.py
|
||||
requête la base fraîche à chaque rendu, jamais ce snapshot. Le port
|
||||
hors ligne (static/js/play/offline/), lui, n'a QUE ce snapshot."""
|
||||
resp = client.post(
|
||||
f"/game/{game}/objects/new",
|
||||
data={
|
||||
"object_name": "Monstre",
|
||||
"field_name[]": ["nom"],
|
||||
"field_type[]": ["texte"],
|
||||
"field_relation[]": [""],
|
||||
"field_required[]": ["0"],
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert resp.status_code == 302, resp.data
|
||||
monstre_definition_id = int(resp.headers["Location"].rstrip("/").split("/")[-1])
|
||||
resp = client.post(f"/game/{game}/objects/{monstre_definition_id}/data/new",
|
||||
data={"nom": "Dragon"}, follow_redirects=False)
|
||||
assert resp.status_code == 302, resp.data
|
||||
import db
|
||||
monstre_row_id = db.list_rows(game, db.get_definition(game, monstre_definition_id))[0]["id"]
|
||||
|
||||
resp = client.post(
|
||||
f"/game/{game}/objects/new",
|
||||
data={
|
||||
"object_name": "Niveau",
|
||||
"field_name[]": ["boss"],
|
||||
"field_type[]": ["relation"],
|
||||
"field_relation[]": [str(monstre_definition_id)],
|
||||
"field_required[]": ["0"],
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert resp.status_code == 302, resp.data
|
||||
definition_id = int(resp.headers["Location"].rstrip("/").split("/")[-1])
|
||||
resp = client.post(f"/game/{game}/objects/{definition_id}/data/new",
|
||||
data={"boss_id": str(monstre_row_id)}, follow_redirects=False)
|
||||
assert resp.status_code == 302, resp.data
|
||||
|
||||
import re
|
||||
resp = client.post(f"/game/{game}/screens/new", data={"name": "Accueil"}, follow_redirects=False)
|
||||
screen_id = int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1))
|
||||
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "texte"}, follow_redirects=False)
|
||||
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": "Boss : {{boss}}", "ctrl_data_definition_id": str(definition_id)},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert resp.status_code == 302, resp.data
|
||||
|
||||
resp = client.post(f"/game/{game}/export-scorm")
|
||||
import io
|
||||
with zipfile.ZipFile(io.BytesIO(resp.data)) as zf:
|
||||
html = zf.read("index.html").decode("utf-8")
|
||||
# Le "content" brut ("Boss : {{boss}}") reste forcément présent tel
|
||||
# quel dans le JSON du payload (c'est le MODÈLE stocké) — seul le
|
||||
# rendered_html déjà substitué compte ici.
|
||||
assert f"Boss : {monstre_row_id}" in html
|
||||
|
||||
|
||||
def test_export_scorm_manifest_lists_index_html_as_the_sco(client, game):
|
||||
resp = client.post(f"/game/{game}/export-scorm")
|
||||
import io
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
"""Le sélecteur "Valeur fixe / Donnée d'un autre objet" (tout contrôle
|
||||
"..._valeur", voir screen_edit.html) propose désormais aussi "Variable
|
||||
globale" — syntaxe interne "{{$nom_variable}}" (voir _VAR_REF_PATTERN,
|
||||
filter_repeater_rows.py), au même titre que "{{Objet.champ}}" pour un
|
||||
objet."""
|
||||
import re
|
||||
|
||||
import db
|
||||
import screens
|
||||
|
||||
|
||||
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 _create_niveau_object(client, slug):
|
||||
resp = client.post(f"/game/{slug}/objects/new", data={
|
||||
"object_name": "Niveau", "field_name[]": ["numero"], "field_type[]": ["nombre_entier"],
|
||||
"field_relation[]": [""], "field_required[]": ["0"], "field_min[]": [""], "field_max[]": [""],
|
||||
}, follow_redirects=False)
|
||||
def_id = int(resp.headers["Location"].rstrip("/").split("/")[-1])
|
||||
client.post(f"/game/{slug}/objects/{def_id}/data/new", data={"numero": "3"})
|
||||
client.post(f"/game/{slug}/objects/{def_id}/data/new", data={"numero": "5"})
|
||||
return def_id
|
||||
|
||||
|
||||
def test_repeater_filter_resolves_a_global_variable_reference(client, game):
|
||||
client.post(f"/game/{game}/variables/new", data={"name": "niveau_actuel", "type": "nombre_entier", "default_value": "3"})
|
||||
def_id = _create_niveau_object(client, game)
|
||||
screen_id = _create_screen(client, game)
|
||||
|
||||
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "repeteur_donnees"}, follow_redirects=False)
|
||||
rep_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
client.post(f"/game/{game}/elements/{rep_id}/save", data={
|
||||
"ctrl_definition_id": str(def_id),
|
||||
"ctrl_filtre_champ": "numero",
|
||||
"ctrl_filtre_operateur": "egal",
|
||||
"ctrl_filtre_valeur": "{{$niveau_actuel}}",
|
||||
})
|
||||
|
||||
payload = client.get(f"/game/{game}/runtime-payload").get_json()
|
||||
html = payload["screens"][0]["elements"][0]["rendered_html"]
|
||||
assert html.count("repeaterItem") == 1
|
||||
|
||||
db.update_global_variable_value(game, "niveau_actuel", "5")
|
||||
payload = client.get(f"/game/{game}/runtime-payload").get_json()
|
||||
html = payload["screens"][0]["elements"][0]["rendered_html"]
|
||||
assert html.count("repeaterItem") == 1
|
||||
|
||||
|
||||
def test_deleted_variable_reference_matches_nothing_rather_than_crashing(client, game):
|
||||
def_id = _create_niveau_object(client, game)
|
||||
screen_id = _create_screen(client, game)
|
||||
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "repeteur_donnees"}, follow_redirects=False)
|
||||
rep_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
client.post(f"/game/{game}/elements/{rep_id}/save", data={
|
||||
"ctrl_definition_id": str(def_id),
|
||||
"ctrl_filtre_champ": "numero",
|
||||
"ctrl_filtre_operateur": "egal",
|
||||
"ctrl_filtre_valeur": "{{$inexistante}}",
|
||||
})
|
||||
payload = client.get(f"/game/{game}/runtime-payload").get_json()
|
||||
html = payload["screens"][0]["elements"][0]["rendered_html"]
|
||||
assert html.count("repeaterItem") == 0
|
||||
|
||||
|
||||
def test_variable_picker_appears_in_the_properties_panel(client, game):
|
||||
client.post(f"/game/{game}/variables/new", data={"name": "niveau_actuel", "type": "nombre_entier", "default_value": "3"})
|
||||
def_id = _create_niveau_object(client, game)
|
||||
screen_id = _create_screen(client, game)
|
||||
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "repeteur_donnees"}, follow_redirects=False)
|
||||
rep_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
client.post(f"/game/{game}/elements/{rep_id}/save", data={"ctrl_definition_id": str(def_id)})
|
||||
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit?selected={rep_id}").data.decode()
|
||||
assert "filterValueVarSel" in html
|
||||
assert '<option value="niveau_actuel">niveau_actuel</option>' in html
|
||||
|
||||
|
||||
def test_repeater_without_template_still_renders_in_play_mode(client, game):
|
||||
"""Non-régression : un Répéteur sans modèle de ligne (texte brut avec
|
||||
{{champ}}) plantait en mode jouable avec TypeError: replace() argument
|
||||
2 must be str, not bool - le ctx porte aussi _forge_play_mode (voir
|
||||
render_element_html.py), un booléen jamais destiné à une substitution
|
||||
{{champ}}."""
|
||||
def_id = _create_niveau_object(client, game)
|
||||
screen_id = _create_screen(client, game)
|
||||
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "repeteur_donnees"}, follow_redirects=False)
|
||||
rep_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
client.post(f"/game/{game}/elements/{rep_id}/save", data={"ctrl_definition_id": str(def_id), "ctrl_content": "Numéro : {{numero}}"})
|
||||
|
||||
resp = client.get(f"/game/{game}/play")
|
||||
assert resp.status_code == 200
|
||||
assert b"Num\xc3\xa9ro : 3" in resp.data
|
||||
+3
-120
@@ -2,6 +2,7 @@
|
||||
import json
|
||||
import re
|
||||
|
||||
import screens
|
||||
from conftest import play_js_bundle
|
||||
|
||||
|
||||
@@ -12,8 +13,7 @@ def _create_screen(client, slug, name="Accueil"):
|
||||
|
||||
def test_flow_node_and_edge_crud(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "bouton"})
|
||||
element_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
element_id = screens.add_scene_object(game, screen_id)
|
||||
|
||||
resp = client.post(
|
||||
f"/game/{game}/screens/{screen_id}/flow/nodes/add",
|
||||
@@ -46,8 +46,7 @@ def test_multiple_actions_from_one_trigger(client, game):
|
||||
"""Régression : un déclencheur doit pouvoir avoir PLUSIEURS arêtes
|
||||
sortantes (plusieurs actions exécutées au même clic)."""
|
||||
screen_id = _create_screen(client, game)
|
||||
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "bouton"})
|
||||
element_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
element_id = screens.add_scene_object(game, screen_id)
|
||||
|
||||
resp = client.post(
|
||||
f"/game/{game}/screens/{screen_id}/flow/nodes/add",
|
||||
@@ -73,73 +72,11 @@ def test_multiple_actions_from_one_trigger(client, game):
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
import screens
|
||||
edges = screens.list_flow_edges(game, screen_id)
|
||||
from_trigger = [e for e in edges if e["from_node_id"] == trigger_node["id"]]
|
||||
assert len(from_trigger) == 2
|
||||
|
||||
|
||||
def test_repeater_rows_carry_stable_row_id(client, game, mail_object):
|
||||
"""Régression 1.1 : contrairement à data-element-id (l'id du MODÈLE de
|
||||
ligne, identique sur chaque ligne rendue), chaque ligne d'un Répéteur
|
||||
doit porter un data-row-id qui identifie la VRAIE ligne de données
|
||||
affichée — sans ça, un déclencheur posé sur le Répéteur ne peut pas
|
||||
savoir laquelle de ses lignes a été cliquée."""
|
||||
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"})
|
||||
|
||||
screen_id = _create_screen(client, slug)
|
||||
resp = client.post(f"/game/{slug}/screens/{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)})
|
||||
|
||||
html = client.get(f"/game/{slug}/screens/{screen_id}/edit").data.decode()
|
||||
assert 'class="repeaterItem" data-row-id="' in html
|
||||
assert f'data-definition-id="{definition_id}"' in html
|
||||
# Deux lignes de données -> deux data-row-id DIFFÉRENTS.
|
||||
row_ids = re.findall(r'data-row-id="(\d+)"', html)
|
||||
assert len(row_ids) == 2
|
||||
assert len(set(row_ids)) == 2
|
||||
|
||||
|
||||
def test_ouvrir_ligne_action_node_persists_target_screen(client, game):
|
||||
"""Régression 1.1 : la nouvelle action "Ouvrir la ligne de Répéteur
|
||||
cliquée" doit accepter un écran cible comme l'action "aller_a", pour
|
||||
que l'écran de détail (ex: lecture d'un mail) puisse être choisi dans
|
||||
l'éditeur de logique."""
|
||||
import json
|
||||
|
||||
inbox_id = _create_screen(client, game, "Boîte de réception")
|
||||
detail_id = _create_screen(client, game, "Détail du mail")
|
||||
resp = client.post(f"/game/{game}/screens/{inbox_id}/elements/add", data={"widget": "repeteur_donnees"})
|
||||
rep_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
|
||||
resp = client.post(
|
||||
f"/game/{game}/screens/{inbox_id}/flow/nodes/add",
|
||||
data=json.dumps({"node_type": "trigger", "trigger_element_id": rep_id, "trigger_event": "clic"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
trigger_node = resp.get_json()
|
||||
|
||||
resp = client.post(
|
||||
f"/game/{game}/screens/{inbox_id}/flow/nodes/add",
|
||||
data=json.dumps({"node_type": "action", "action_type": "ouvrir_ligne", "target_screen_id": detail_id}),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
action_node = resp.get_json()
|
||||
assert action_node["action_type"] == "ouvrir_ligne"
|
||||
assert action_node["target_screen_id"] == detail_id
|
||||
|
||||
resp = client.post(
|
||||
f"/game/{game}/screens/{inbox_id}/flow/edges/add",
|
||||
data=json.dumps({"from_node_id": trigger_node["id"], "from_port": "out", "to_node_id": action_node["id"]}),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
def test_play_page_exposes_row_bindings_helpers(client, game):
|
||||
"""Le runtime jouable doit embarquer la logique côté client capable de
|
||||
résoudre les {{champ}} de la ligne ouverte (voir README, section
|
||||
@@ -151,60 +88,6 @@ def test_play_page_exposes_row_bindings_helpers(client, game):
|
||||
assert "'ouvrir_ligne'" in js
|
||||
|
||||
|
||||
def test_repeater_filter_literal_value(client, game, mail_object):
|
||||
"""Régression 1.2 : un filtre réglé sur le Répéteur (champ + opérateur +
|
||||
valeur fixe) ne doit garder que les lignes qui correspondent, sur un
|
||||
écran UNIQUE (pas un écran par niveau) — voir claude/forge-engine-lacunes-boitemail.md."""
|
||||
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"})
|
||||
|
||||
screen_id = _create_screen(client, slug)
|
||||
resp = client.post(f"/game/{slug}/screens/{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_content": "{{sujet}}",
|
||||
"ctrl_filtre_champ": "sujet", "ctrl_filtre_operateur": "egal", "ctrl_filtre_valeur": "Sujet 2",
|
||||
})
|
||||
|
||||
html = client.get(f"/game/{slug}/screens/{screen_id}/edit").data.decode()
|
||||
rows = re.findall(r'<div class="repeaterItem"[^>]*>(.*?)</div>', html)
|
||||
assert len(rows) == 1, f"le filtre aurait dû ne garder qu'UNE ligne, en a laissé {len(rows)}"
|
||||
assert "Sujet 2" in rows[0]
|
||||
|
||||
|
||||
def test_repeater_filter_reference_to_another_object(client, game, mail_object):
|
||||
"""Régression 1.2 : le filtre doit aussi savoir comparer à une valeur qui
|
||||
change en cours de partie via "{{NomObjet.champ}}" (ex: {{Partie.niveau}}),
|
||||
pas seulement à une valeur fixe — c'est ce qui permet à UN SEUL écran de
|
||||
s'adapter à la progression sans dupliquer un écran par niveau."""
|
||||
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"})
|
||||
|
||||
# Objet "Partie" à une seule ligne, jouant le rôle d'état global de partie.
|
||||
resp = client.post(f"/game/{slug}/objects/new", data={
|
||||
"object_name": "Partie", "field_name[]": ["sujet_actif"], "field_type[]": ["texte"],
|
||||
"field_relation[]": [""], "field_required[]": ["0"],
|
||||
}, follow_redirects=False)
|
||||
partie_def_id = int(resp.headers["Location"].rstrip("/").split("/")[-1])
|
||||
client.post(f"/game/{slug}/objects/{partie_def_id}/data/new", data={"sujet_actif": "Sujet 1"})
|
||||
|
||||
screen_id = _create_screen(client, slug)
|
||||
resp = client.post(f"/game/{slug}/screens/{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_content": "{{sujet}}",
|
||||
"ctrl_filtre_champ": "sujet", "ctrl_filtre_operateur": "egal",
|
||||
"ctrl_filtre_valeur": "{{Partie.sujet_actif}}",
|
||||
})
|
||||
|
||||
html = client.get(f"/game/{slug}/screens/{screen_id}/edit").data.decode()
|
||||
rows = re.findall(r'<div class="repeaterItem"[^>]*>(.*?)</div>', html)
|
||||
assert len(rows) == 1, f"le filtre aurait dû ne garder qu'UNE ligne, en a laissé {len(rows)}"
|
||||
assert "Sujet 1" in rows[0]
|
||||
|
||||
|
||||
def test_affichage_trigger_does_not_require_element(client, game):
|
||||
"""Régression 1.2 : le nouveau déclencheur "affichage" doit pouvoir être
|
||||
créé SANS élément cible (il concerne l'écran entier), contrairement au
|
||||
|
||||
+10
-18
@@ -118,27 +118,19 @@ def test_deleting_a_block_cascades_only_its_own_nodes_and_edges(client, game):
|
||||
assert screens.get_flow_node(game, node_b1) is not None
|
||||
|
||||
|
||||
def test_screen_edit_page_shows_correct_node_count_right_after_migration(client, game):
|
||||
def test_screen_edit_page_loads_after_orphan_node_migration(client, game):
|
||||
"""routes/screens/screen_edit.py doit lancer list_flow_blocks() (qui
|
||||
migre les nœuds orphelins vers "Bloc principal") AVANT list_flow_nodes()
|
||||
— sinon la page affiche 0 nœud pour "Bloc principal" au tout premier
|
||||
chargement suivant la migration (block_id périmé en mémoire)."""
|
||||
chargement suivant la migration (block_id périmé en mémoire). Porté sur
|
||||
jeu_2d (voir routes/scenes/scene_edit_view.py, blocks_view) — l'onglet
|
||||
"Blocs de logique" lui-même n'existe que dans l'éditeur document
|
||||
retiré ; on vérifie ici directement l'état migré plutôt que son rendu."""
|
||||
screen_id = _create_screen(client, game)
|
||||
_add_flow_node(client, game, screen_id, node_type="trigger", trigger_event="affichage")
|
||||
node_id = _add_flow_node(client, game, screen_id, node_type="trigger", trigger_event="affichage")
|
||||
resp = client.get(f"/game/{game}/screens/{screen_id}/edit")
|
||||
assert resp.status_code == 200
|
||||
assert b"Bloc principal" in resp.data
|
||||
# Le compte de nœuds (colonne "Nœuds") doit valoir 1, pas 0 — cherche
|
||||
# la cellule <td>1</td> qui suit le nom du bloc dans le tableau.
|
||||
html = resp.get_data(as_text=True)
|
||||
row = html[html.index("Bloc principal"):]
|
||||
assert re.search(r"<td>1</td>", row)
|
||||
|
||||
|
||||
def test_screen_edit_page_renders_blocks_tab(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
screens.create_flow_block(game, screen_id, "Ouvrir la porte", "Ouvre la porte. " * 5)
|
||||
resp = client.get(f"/game/{game}/screens/{screen_id}/edit")
|
||||
assert resp.status_code == 200
|
||||
assert b"Blocs de logique" in resp.data
|
||||
assert b"Ouvrir la porte" in resp.data
|
||||
blocks = screens.list_flow_blocks(game, screen_id)
|
||||
assert len(blocks) == 1
|
||||
assert blocks[0]["name"] == "Bloc principal"
|
||||
assert screens.get_flow_node(game, node_id)["block_id"] == blocks[0]["id"]
|
||||
|
||||
@@ -10,22 +10,16 @@ import re
|
||||
import db
|
||||
import screens
|
||||
|
||||
from conftest import screen_edit_js_bundle
|
||||
|
||||
|
||||
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 _create_email_object(client, slug):
|
||||
resp = client.post(f"/game/{slug}/objects/new", data={
|
||||
"object_name": "Email", "field_name[]": ["is_opened"], "field_type[]": ["booleen"],
|
||||
"field_relation[]": [""], "field_required[]": ["0"], "field_min[]": [""], "field_max[]": [""],
|
||||
}, follow_redirects=False)
|
||||
def_id = int(resp.headers["Location"].rstrip("/").split("/")[-1])
|
||||
client.post(f"/game/{slug}/objects/{def_id}/data/new", data={})
|
||||
client.post(f"/game/{slug}/objects/{def_id}/data/new", data={})
|
||||
def_id = db.create_definition(slug, "Email", [{"name": "is_opened", "type": "booleen"}])
|
||||
definition = db.get_definition(slug, def_id)
|
||||
db.insert_row(slug, definition, {})
|
||||
db.insert_row(slug, definition, {})
|
||||
return def_id
|
||||
|
||||
|
||||
@@ -102,13 +96,3 @@ def test_ordinary_fixed_row_target_is_unaffected(client, game):
|
||||
definition = db.get_definition(game, def_id)
|
||||
assert db.get_row(game, definition, 1)["is_opened"]
|
||||
assert not db.get_row(game, definition, 2)["is_opened"]
|
||||
|
||||
|
||||
def test_clicked_row_option_appears_in_the_flow_node_editor(client, game):
|
||||
_create_email_object(client, game)
|
||||
screen_id = _create_screen(client, game)
|
||||
resp = client.get(f"/game/{game}/screens/{screen_id}/edit")
|
||||
assert resp.status_code == 200
|
||||
js = screen_edit_js_bundle()
|
||||
assert "FLOW_CLICKED_ROW_ID" in js
|
||||
assert "Ligne cliquée" in js
|
||||
|
||||
+13
-20
@@ -1,35 +1,26 @@
|
||||
"""Type d'écran (fusion des moteurs, voir screens/screens_repo/
|
||||
ensure_schema.py) : "document" (éditeur générique, éléments en %) ou
|
||||
"jeu_2d" (éditeur de scène, objets en pixels) — choisi PAR ÉCRAN, à sa
|
||||
création, plus par jeu. Un jeu peut librement mélanger les deux."""
|
||||
"""Type d'écran (voir screens/screens_repo/ensure_schema.py) : un seul
|
||||
moteur d'écran désormais, "jeu_2d" (éditeur de scène, objets en pixels) —
|
||||
le type "document" (éditeur générique) a été retiré."""
|
||||
import db
|
||||
import screens
|
||||
|
||||
|
||||
def test_default_screen_kind_is_document(client, game):
|
||||
def test_default_screen_kind_is_jeu_2d(client, game):
|
||||
screen_id = screens.create_screen(game, "Accueil")
|
||||
assert screens.get_screen(game, screen_id)["kind"] == "document"
|
||||
assert screens.get_screen(game, screen_id)["kind"] == "jeu_2d"
|
||||
|
||||
|
||||
def test_create_screen_with_jeu_2d_kind(tmp_game_slug_cleanup):
|
||||
def test_create_screen_with_explicit_jeu_2d_kind(tmp_game_slug_cleanup):
|
||||
slug = tmp_game_slug_cleanup(db.create_game("pytest_jeu2d_test"))
|
||||
screen_id = screens.create_screen(slug, "Scène 1", kind="jeu_2d")
|
||||
screen = screens.get_screen(slug, screen_id)
|
||||
assert screen["kind"] == "jeu_2d"
|
||||
|
||||
|
||||
def test_a_game_can_mix_both_screen_kinds(tmp_game_slug_cleanup):
|
||||
slug = tmp_game_slug_cleanup(db.create_game("pytest_mixed_test"))
|
||||
doc_id = screens.create_screen(slug, "Accueil", kind="document")
|
||||
scene_id = screens.create_screen(slug, "Scène 1", kind="jeu_2d")
|
||||
assert screens.get_screen(slug, doc_id)["kind"] == "document"
|
||||
assert screens.get_screen(slug, scene_id)["kind"] == "jeu_2d"
|
||||
|
||||
|
||||
def test_screens_new_route_creates_jeu_2d_screen(client, game):
|
||||
def test_screens_new_route_always_creates_jeu_2d_screens(client, game):
|
||||
resp = client.post(
|
||||
f"/game/{game}/screens/new",
|
||||
data={"name": "pytest_route_jeu2d", "kind": "jeu_2d"},
|
||||
data={"name": "pytest_route_jeu2d"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert resp.status_code == 302
|
||||
@@ -37,12 +28,14 @@ def test_screens_new_route_creates_jeu_2d_screen(client, game):
|
||||
assert screens.get_screen(game, screen_id)["kind"] == "jeu_2d"
|
||||
|
||||
|
||||
def test_screens_new_route_rejects_unknown_kind(client, game):
|
||||
def test_screens_new_route_ignores_any_kind_sent_by_the_client(client, game):
|
||||
"""Il n'y a plus de sélecteur de type — un "kind" envoyé malgré tout
|
||||
(formulaire personnalisé, requête manuelle) est ignoré, jamais lu."""
|
||||
resp = client.post(
|
||||
f"/game/{game}/screens/new",
|
||||
data={"name": "pytest_route_bad_kind", "kind": "n_importe_quoi"},
|
||||
data={"name": "pytest_route_bad_kind", "kind": "document"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert resp.status_code == 302
|
||||
screen_id = int(resp.headers["Location"].rstrip("/").split("/")[-2])
|
||||
assert screens.get_screen(game, screen_id)["kind"] == "document"
|
||||
assert screens.get_screen(game, screen_id)["kind"] == "jeu_2d"
|
||||
|
||||
@@ -1,272 +0,0 @@
|
||||
"""Tests du widget Jauge (2.1 : binding live d'une donnée vers un style)."""
|
||||
import re
|
||||
|
||||
import db
|
||||
|
||||
from conftest import play_js_bundle
|
||||
|
||||
|
||||
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 _create_partie_object(client, slug, field_name="reputation", value="50"):
|
||||
resp = client.post(f"/game/{slug}/objects/new", data={
|
||||
"object_name": "Partie", "field_name[]": [field_name], "field_type[]": ["texte"],
|
||||
"field_relation[]": [""], "field_required[]": ["0"],
|
||||
}, follow_redirects=False)
|
||||
def_id = int(resp.headers["Location"].rstrip("/").split("/")[-1])
|
||||
client.post(f"/game/{slug}/objects/{def_id}/data/new", data={field_name: value})
|
||||
return def_id
|
||||
|
||||
|
||||
def test_gauge_width_reflects_field_value_without_extra_action(client, game):
|
||||
"""Régression 2.1 : la largeur de la jauge doit refléter automatiquement
|
||||
la valeur du champ suivi, sans qu'aucune action "Modifier un élément"
|
||||
n'ait été posée dans le graphe de logique."""
|
||||
def_id = _create_partie_object(client, game, "reputation", "50")
|
||||
screen_id = _create_screen(client, game)
|
||||
|
||||
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "jauge"}, follow_redirects=False)
|
||||
jauge_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
client.post(f"/game/{game}/elements/{jauge_id}/save", data={
|
||||
"ctrl_definition_id": str(def_id), "ctrl_champ": "reputation", "ctrl_min": "0", "ctrl_max": "100",
|
||||
})
|
||||
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
|
||||
assert 'value="50.0"' in html
|
||||
|
||||
|
||||
def test_gauge_updates_after_data_change_with_no_extra_wiring(client, game):
|
||||
"""Régression 2.1 : quand la donnée change (ex: une action "Modifier une
|
||||
donnée" exécutée en jeu), la jauge doit refléter la NOUVELLE valeur au
|
||||
prochain rendu — sans binding manuel à recâbler."""
|
||||
def_id = _create_partie_object(client, game, "reputation", "50")
|
||||
screen_id = _create_screen(client, game)
|
||||
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "jauge"}, follow_redirects=False)
|
||||
jauge_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
client.post(f"/game/{game}/elements/{jauge_id}/save", data={
|
||||
"ctrl_definition_id": str(def_id), "ctrl_champ": "reputation", "ctrl_min": "0", "ctrl_max": "100",
|
||||
})
|
||||
|
||||
# Simule une action "Modifier une donnée" qui change la valeur en base.
|
||||
client.post(f"/game/{game}/objects/{def_id}/data/new", data={"reputation": "90"})
|
||||
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
|
||||
assert 'value="90.0"' in html
|
||||
|
||||
|
||||
def test_gauge_color_gradient_shifts_between_low_and_high(client, game):
|
||||
"""Régression 2.1 : la couleur de remplissage doit se rapprocher de la
|
||||
couleur "maxi" quand la valeur est haute, pas rester figée."""
|
||||
def_id = _create_partie_object(client, game, "reputation", "100")
|
||||
screen_id = _create_screen(client, game)
|
||||
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "jauge"}, follow_redirects=False)
|
||||
jauge_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
client.post(f"/game/{game}/elements/{jauge_id}/save", data={
|
||||
"ctrl_definition_id": str(def_id), "ctrl_champ": "reputation", "ctrl_min": "0", "ctrl_max": "100",
|
||||
"ctrl_couleur_basse": "#ff0000", "ctrl_couleur_haute": "#00ff00",
|
||||
})
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
|
||||
assert "--bulma-progress-value-background-color:#00ff00" in html # valeur au max -> couleur "haute" pure
|
||||
|
||||
|
||||
def test_gauge_value_clamped_visually_when_out_of_range(client, game):
|
||||
"""Une valeur au-delà du maxi réglé (avant que 2.2 n'empêche ça à la
|
||||
source) ne doit pas casser l'affichage — la jauge doit rester à 100%,
|
||||
pas déborder."""
|
||||
def_id = _create_partie_object(client, game, "reputation", "500")
|
||||
screen_id = _create_screen(client, game)
|
||||
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "jauge"}, follow_redirects=False)
|
||||
jauge_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
client.post(f"/game/{game}/elements/{jauge_id}/save", data={
|
||||
"ctrl_definition_id": str(def_id), "ctrl_champ": "reputation", "ctrl_min": "0", "ctrl_max": "100",
|
||||
})
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
|
||||
assert 'value="100.0"' in html
|
||||
|
||||
|
||||
# ---------- Choix de l'enregistrement (plusieurs jauges dans un même objet)
|
||||
# et champ affiché comme nom au-dessus de la barre ----------
|
||||
|
||||
def _create_jauge_object_with_rows(client, slug):
|
||||
"""Un seul objet "jauge" avec trois enregistrements (name/value), comme
|
||||
dans le scénario remonté : Réputation/Trésorerie/Confiance, toutes à 50
|
||||
— le bug était de ne pas pouvoir distinguer laquelle une Jauge suit."""
|
||||
resp = client.post(f"/game/{slug}/objects/new", data={
|
||||
"object_name": "jauge",
|
||||
"field_name[]": ["name", "value"], "field_type[]": ["texte", "nombre_entier"],
|
||||
"field_relation[]": ["", ""], "field_required[]": ["0", "0"],
|
||||
}, follow_redirects=False)
|
||||
def_id = int(resp.headers["Location"].rstrip("/").split("/")[-1])
|
||||
for name, value in [("Réputation", "50"), ("Trésorerie", "20"), ("Confiance", "80")]:
|
||||
client.post(f"/game/{slug}/objects/{def_id}/data/new", data={"name": name, "value": value}, follow_redirects=False)
|
||||
# La route de création redirige vers la liste (pas d'id de ligne dans le
|
||||
# Location) — on relit les lignes enregistrées pour retrouver leurs id.
|
||||
definition = db.get_definition(slug, def_id)
|
||||
row_ids = {r["name"]: r["id"] for r in db.list_rows(slug, definition)}
|
||||
return def_id, row_ids
|
||||
|
||||
|
||||
def test_gauge_row_id_distinguishes_between_records_of_the_same_object(client, game):
|
||||
"""Le bug remonté : trois Jauges reliées au même objet (via "value")
|
||||
affichaient toutes la même valeur (celle de la ligne la plus récente),
|
||||
faute de pouvoir choisir LAQUELLE suivre. row_id doit permettre de
|
||||
cibler "Confiance" (80%) plutôt que "Trésorerie" (20%)."""
|
||||
def_id, row_ids = _create_jauge_object_with_rows(client, game)
|
||||
screen_id = _create_screen(client, game)
|
||||
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "jauge"}, follow_redirects=False)
|
||||
jauge_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
client.post(f"/game/{game}/elements/{jauge_id}/save", data={
|
||||
"ctrl_definition_id": str(def_id), "ctrl_row_id": str(row_ids["Confiance"]),
|
||||
"ctrl_champ": "value", "ctrl_min": "0", "ctrl_max": "100",
|
||||
})
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
|
||||
assert 'value="80.0"' in html
|
||||
|
||||
|
||||
def test_gauge_without_row_id_falls_back_to_latest_row_no_regression(client, game):
|
||||
"""Sans row_id réglé (jauges créées avant ce champ, ou objet à une seule
|
||||
ligne comme dans test_jauge.py historique) : comportement inchangé, la
|
||||
ligne la plus récente."""
|
||||
def_id, _row_ids = _create_jauge_object_with_rows(client, game)
|
||||
screen_id = _create_screen(client, game)
|
||||
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "jauge"}, follow_redirects=False)
|
||||
jauge_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
client.post(f"/game/{game}/elements/{jauge_id}/save", data={
|
||||
"ctrl_definition_id": str(def_id), "ctrl_champ": "value", "ctrl_min": "0", "ctrl_max": "100",
|
||||
})
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
|
||||
assert 'value="80.0"' in html # "Confiance", ajoutée en dernier
|
||||
|
||||
|
||||
def test_gauge_name_field_renders_above_the_bar(client, game):
|
||||
def_id, row_ids = _create_jauge_object_with_rows(client, game)
|
||||
screen_id = _create_screen(client, game)
|
||||
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "jauge"}, follow_redirects=False)
|
||||
jauge_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
client.post(f"/game/{game}/elements/{jauge_id}/save", data={
|
||||
"ctrl_definition_id": str(def_id), "ctrl_row_id": str(row_ids["Confiance"]),
|
||||
"ctrl_champ": "value", "ctrl_champ_nom": "name", "ctrl_min": "0", "ctrl_max": "100",
|
||||
})
|
||||
html = client.get(f"/game/{game}/play").data.decode()
|
||||
assert ">Confiance<" in html
|
||||
|
||||
|
||||
def test_gauge_without_champ_nom_shows_no_name_no_regression(client, game):
|
||||
def_id, row_ids = _create_jauge_object_with_rows(client, game)
|
||||
screen_id = _create_screen(client, game)
|
||||
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "jauge"}, follow_redirects=False)
|
||||
jauge_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
client.post(f"/game/{game}/elements/{jauge_id}/save", data={
|
||||
"ctrl_definition_id": str(def_id), "ctrl_row_id": str(row_ids["Confiance"]),
|
||||
"ctrl_champ": "value", "ctrl_min": "0", "ctrl_max": "100",
|
||||
})
|
||||
html = client.get(f"/game/{game}/play").data.decode()
|
||||
assert ">Confiance<" not in html
|
||||
|
||||
|
||||
def test_gauge_bar_stays_visible_when_nested_in_a_container_with_a_name(client, game):
|
||||
"""Régression : posée À L'INTÉRIEUR d'un conteneur (donc en hauteur
|
||||
"auto", voir _style_string), la Jauge avec un nom affiché au-dessus ne
|
||||
doit pas voir sa barre s'effondrer à 0px — "flex:1 1 auto" seul n'a
|
||||
rien à répartir dans un conteneur flex sans hauteur définie, d'où le
|
||||
"min-height" plancher sur le wrapper de la barre (voir render_jauge.py).
|
||||
On vérifie ici que la classe "jaugeBar" reste dans un wrapper qui porte
|
||||
bien un min-height non nul, pas seulement sa présence dans le HTML."""
|
||||
def_id, row_ids = _create_jauge_object_with_rows(client, game)
|
||||
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))
|
||||
resp = client.post(f"/game/{game}/elements/{container_id}/children/add", data={"widget": "jauge"}, follow_redirects=False)
|
||||
jauge_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
client.post(f"/game/{game}/elements/{jauge_id}/save", data={
|
||||
"ctrl_definition_id": str(def_id), "ctrl_row_id": str(row_ids["Confiance"]),
|
||||
"ctrl_champ": "value", "ctrl_champ_nom": "name", "ctrl_min": "0", "ctrl_max": "100",
|
||||
})
|
||||
html = client.get(f"/game/{game}/play").data.decode()
|
||||
assert ">Confiance<" in html
|
||||
idx = html.find("jaugeBar")
|
||||
div_start = html.rfind('<div', 0, idx)
|
||||
wrap_style = html[div_start:html.find('>', div_start)]
|
||||
assert "min-height:14px" in wrap_style
|
||||
|
||||
|
||||
def test_gauge_name_position_beside_uses_row_layout(client, game):
|
||||
def_id, row_ids = _create_jauge_object_with_rows(client, game)
|
||||
screen_id = _create_screen(client, game)
|
||||
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "jauge"}, follow_redirects=False)
|
||||
jauge_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
client.post(f"/game/{game}/elements/{jauge_id}/save", data={
|
||||
"ctrl_definition_id": str(def_id), "ctrl_row_id": str(row_ids["Confiance"]),
|
||||
"ctrl_champ": "value", "ctrl_champ_nom": "name", "ctrl_min": "0", "ctrl_max": "100",
|
||||
"ctrl_nom_position": "cote",
|
||||
})
|
||||
html = client.get(f"/game/{game}/play").data.decode()
|
||||
idx = html.find(f'data-element-id="{jauge_id}"')
|
||||
style = html[html.find('style="', idx):html.find('>', idx)]
|
||||
assert "flex-direction:row" in style
|
||||
|
||||
|
||||
def test_gauge_name_appearance_controls_render_on_the_label(client, game):
|
||||
"""Police, taille, gras et italique du nom doivent se retrouver dans le
|
||||
style inline du libellé — et la taille (contrôle slider sans unité
|
||||
stockée, voir registry.py) ne doit pas finir dupliquée ("18pxpx")."""
|
||||
def_id, row_ids = _create_jauge_object_with_rows(client, game)
|
||||
screen_id = _create_screen(client, game)
|
||||
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "jauge"}, follow_redirects=False)
|
||||
jauge_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
client.post(f"/game/{game}/elements/{jauge_id}/save", data={
|
||||
"ctrl_definition_id": str(def_id), "ctrl_row_id": str(row_ids["Confiance"]),
|
||||
"ctrl_champ": "value", "ctrl_champ_nom": "name", "ctrl_min": "0", "ctrl_max": "100",
|
||||
"ctrl_nom_police": "Georgia, serif", "ctrl_nom_taille": "18",
|
||||
"ctrl_nom_gras": "1", "ctrl_nom_italique": "1",
|
||||
})
|
||||
html = client.get(f"/game/{game}/play").data.decode()
|
||||
idx = html.find(">Confiance<")
|
||||
style = html[html.rfind('style="', 0, idx):idx]
|
||||
assert "font-family:Georgia, serif" in style
|
||||
assert "font-size:18px" in style
|
||||
assert "18pxpx" not in style
|
||||
assert "font-weight:700" in style
|
||||
assert "font-style:italic" in style
|
||||
|
||||
|
||||
def test_gauge_name_left_alignment_only_applies_when_above(client, game):
|
||||
def_id, row_ids = _create_jauge_object_with_rows(client, game)
|
||||
screen_id = _create_screen(client, game)
|
||||
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "jauge"}, follow_redirects=False)
|
||||
jauge_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
client.post(f"/game/{game}/elements/{jauge_id}/save", data={
|
||||
"ctrl_definition_id": str(def_id), "ctrl_row_id": str(row_ids["Confiance"]),
|
||||
"ctrl_champ": "value", "ctrl_champ_nom": "name", "ctrl_min": "0", "ctrl_max": "100",
|
||||
"ctrl_nom_position": "dessus", "ctrl_nom_alignement": "gauche",
|
||||
})
|
||||
html = client.get(f"/game/{game}/play").data.decode()
|
||||
idx = html.find(">Confiance<")
|
||||
style = html[html.rfind('style="', 0, idx):idx]
|
||||
assert "text-align:left" in style
|
||||
|
||||
|
||||
# ---------- Régression : clics multipliés après un rafraîchissement de
|
||||
# données (bouton +10/-10 relié à une jauge) ----------
|
||||
|
||||
def test_play_page_click_binding_guards_against_duplicate_listeners(client, game):
|
||||
"""Bug remonté par l'utilisateur : après plusieurs clics sur un bouton
|
||||
relié à une action "Modifier une donnée" (ex: +10 points), l'effet
|
||||
devenait instable (parfois -20, 0, +100 au lieu de ±10 par clic).
|
||||
|
||||
Cause : bindClicks() est rappelée après CHAQUE refreshRuntimeData() (donc
|
||||
après chaque donnée modifiée), et un bouton ordinaire (ni Répéteur, ni
|
||||
Jauge) garde le MÊME nœud DOM d'un rafraîchissement à l'autre — sans
|
||||
garde-fou, addEventListener('click') s'empile à chaque rafraîchissement,
|
||||
et un seul clic finit par déclencher l'action plusieurs fois d'affilée.
|
||||
|
||||
On vérifie ici la présence du garde-fou (clickBound) qui empêche ce
|
||||
ré-attachement — un vrai test de non-régression comportementale (clics
|
||||
répétés via un navigateur réel) a été exécuté manuellement pour valider
|
||||
le correctif : 5 clics sur +10 -> exactement +50, jamais plus."""
|
||||
js = play_js_bundle()
|
||||
assert "clickBound" in js
|
||||
assert "if (elDiv.dataset.clickBound) return;" in js
|
||||
@@ -6,7 +6,7 @@ on vérifie que la logique existe dans le JS livré, pas qu'elle s'exécute)."""
|
||||
import json
|
||||
import re
|
||||
|
||||
from conftest import play_js_bundle, screen_edit_js_bundle
|
||||
from conftest import play_js_bundle
|
||||
|
||||
import screens
|
||||
|
||||
@@ -63,12 +63,3 @@ def test_play_page_exposes_keyboard_and_timer_trigger_runtime(client, game):
|
||||
assert "runScreenTimerTriggers" in js
|
||||
assert "'minuteur'" in js
|
||||
assert "trigger_interval_ms" in js
|
||||
|
||||
|
||||
def test_screen_edit_exposes_keyboard_and_timer_trigger_editor(client, game):
|
||||
html = client.get(f"/game/{game}/screens/{_create_screen(client, game)}/edit").get_data(as_text=True)
|
||||
assert "nf-trigger-key-row" in html
|
||||
assert "nf-trigger-interval-row" in html
|
||||
js = screen_edit_js_bundle()
|
||||
assert "captureFlowTriggerKey" in js
|
||||
assert "trigger_interval_ms" in js
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
"""Régression : le panneau "+ Nouvel objet" du tableau de bord (game_dashboard.html)
|
||||
soumet le formulaire dès qu'on tape un nom et clique "Créer l'objet",
|
||||
SANS avoir forcément cliqué "+ Ajouter un champ" au moins une fois avant
|
||||
(les champs se posent typiquement APRÈS, depuis le panneau "Modifier un
|
||||
objet"). routes/objects/object_new.py exigeait pourtant au moins un champ
|
||||
pour créer quoi que ce soit ("if name and fields"), sans le moindre
|
||||
message d'erreur en cas d'échec — juste un aller-retour silencieux vers le
|
||||
tableau de bord, vécu comme "le panneau recharge la page sans créer
|
||||
d'objet"."""
|
||||
import db
|
||||
|
||||
|
||||
def test_object_created_with_a_name_but_no_field(client, game):
|
||||
resp = client.post(f"/game/{game}/objects/new", data={"object_name": "Compteur"}, follow_redirects=False)
|
||||
assert resp.status_code == 302
|
||||
assert "/objects/" in resp.headers["Location"]
|
||||
|
||||
definitions = db.list_definitions(game)
|
||||
assert len(definitions) == 1
|
||||
assert definitions[0]["name"] == "Compteur"
|
||||
assert db.get_definition(game, definitions[0]["id"])["fields"] == []
|
||||
|
||||
|
||||
def test_object_not_created_without_a_name(client, game):
|
||||
resp = client.post(f"/game/{game}/objects/new", data={"object_name": " "}, follow_redirects=False)
|
||||
assert resp.status_code == 302
|
||||
assert db.list_definitions(game) == []
|
||||
|
||||
|
||||
def test_relation_field_without_a_chosen_target_does_not_crash_the_whole_request(client, game):
|
||||
"""Régression : "Relation vers un autre objet" est TOUJOURS une option
|
||||
du sélecteur de type (db/constants.py), même quand il n'existe encore
|
||||
AUCUN autre objet à pointer (ex. le tout premier objet créé dans un
|
||||
jeu) — le sélecteur "Objet lié" est alors vide, et
|
||||
create_definition() faisait int("") sans filet, plantant TOUTE la
|
||||
requête (500) avant même d'atteindre son commit() : l'objet n'était
|
||||
donc PAS créé du tout, pas seulement son champ "Relation" — vécu comme
|
||||
"le panneau recharge la page sans créer d'objet" alors que des champs
|
||||
avaient bien été renseignés. Le champ "Relation" invalide doit être
|
||||
ignoré (comme une ligne sans nom), pas faire échouer tout le reste."""
|
||||
resp = client.post(f"/game/{game}/objects/new", data={
|
||||
"object_name": "Personnage",
|
||||
"field_name[]": ["nom", "arme"],
|
||||
"field_type[]": ["texte", "relation"],
|
||||
"field_relation[]": ["", ""],
|
||||
"field_required[]": ["0", "0"],
|
||||
"field_min[]": ["", ""],
|
||||
"field_max[]": ["", ""],
|
||||
}, follow_redirects=False)
|
||||
assert resp.status_code == 302
|
||||
definitions = db.list_definitions(game)
|
||||
assert len(definitions) == 1
|
||||
fields = db.get_definition(game, definitions[0]["id"])["fields"]
|
||||
assert [f["name"] for f in fields] == ["nom"]
|
||||
|
||||
|
||||
def test_add_relation_field_without_a_chosen_target_is_ignored_not_a_crash(client, game):
|
||||
"""Même garde, côté "+ Ajouter un champ" du panneau d'un objet déjà
|
||||
créé (object_field_add.py / add_field_to_definition.py)."""
|
||||
resp = client.post(f"/game/{game}/objects/new", data={
|
||||
"object_name": "Personnage", "field_name[]": ["nom"], "field_type[]": ["texte"],
|
||||
"field_relation[]": [""], "field_required[]": ["0"], "field_min[]": [""], "field_max[]": [""],
|
||||
}, follow_redirects=False)
|
||||
def_id = int(resp.headers["Location"].rstrip("/").split("/")[-1])
|
||||
|
||||
resp = client.post(f"/game/{game}/objects/{def_id}/fields/add", data={
|
||||
"field_name": "arme", "field_type": "relation", "field_relation": "",
|
||||
}, follow_redirects=False)
|
||||
assert resp.status_code == 302
|
||||
fields = db.get_definition(game, def_id)["fields"]
|
||||
assert [f["name"] for f in fields] == ["nom"]
|
||||
+30
-60
@@ -1,8 +1,9 @@
|
||||
"""Onboarding guidé (routes/onboarding/onboarding_new.py) : un compte
|
||||
"user" fraîchement inscrit choisit un type de jeu (Quiz/Embranchement/
|
||||
RPG/Créer mon jeu de A à Z) avant d'avoir accès à quoi que ce soit — voir
|
||||
le plan "Fusion des deux moteurs + structure de dossiers + onboarding
|
||||
guidé". Réutilise les fixtures de test_auth.py (inscription/2FA)."""
|
||||
"user" fraîchement inscrit choisit un type de jeu avant d'avoir accès à
|
||||
quoi que ce soit — désormais un seul type, "rpg" (jeu RPG 2D), les types
|
||||
Quiz/Embranchement/"Créer mon jeu de A à Z" ayant été retirés avec le
|
||||
moteur d'écran "document". Réutilise les fixtures de test_auth.py
|
||||
(inscription/2FA)."""
|
||||
import os
|
||||
|
||||
import auth
|
||||
@@ -22,52 +23,22 @@ def test_account_without_a_project_is_redirected_to_onboarding(anon_client):
|
||||
|
||||
resp = anon_client.get("/onboarding")
|
||||
assert resp.status_code == 200
|
||||
assert "Quiz" in resp.get_data(as_text=True)
|
||||
assert "Créer un jeu 2D ludique" in resp.get_data(as_text=True)
|
||||
finally:
|
||||
_cleanup_project("needsonboarding@example.com")
|
||||
|
||||
|
||||
def test_quiz_lands_on_simplified_dashboard_with_one_document_screen(anon_client):
|
||||
"""Le dashboard simplifié (routes/games/game_dashboard.py) — un compte
|
||||
restreint (quiz/embranchement/rpg) atterrit maintenant sur SON
|
||||
tableau de bord (même route que "custom"), juste rendu en version
|
||||
simplifiée : que des écrans en cartes, pas d'onglets Objets/Éléments
|
||||
de jeu/Variables (réservés à "Créer mon jeu de A à Z")."""
|
||||
_register(anon_client, "quizuser@example.com")
|
||||
_confirm_2fa(anon_client)
|
||||
try:
|
||||
resp = _complete_onboarding(anon_client, name="Mon Quiz", onboarding_type="quiz")
|
||||
assert resp.status_code == 302
|
||||
user = auth.get_user_by_email("quizuser@example.com")
|
||||
slug = user["project_slug"]
|
||||
assert resp.headers["Location"] == f"/game/{slug}"
|
||||
assert db.get_onboarding_type(slug) == "quiz"
|
||||
|
||||
screen_list = screens.list_screens(slug)
|
||||
assert len(screen_list) == 1
|
||||
assert screen_list[0]["kind"] == "document"
|
||||
|
||||
html = anon_client.get(f"/game/{slug}").get_data(as_text=True)
|
||||
assert "screenCardGrid" in html
|
||||
assert "+ Ajouter un écran" in html
|
||||
assert 'name="kind"' not in html # pas de choix de type à l'ajout
|
||||
assert "openNewObjectPanel" not in html # onglet Objets du dashboard complet absent
|
||||
|
||||
# L'éditeur de l'écran, lui, reste atteignable comme avant.
|
||||
edit_html = anon_client.get(f"/game/{slug}/screens/{screen_list[0]['id']}/edit").get_data(as_text=True)
|
||||
assert "scene-editor.js" not in edit_html
|
||||
finally:
|
||||
_cleanup_project("quizuser@example.com")
|
||||
|
||||
|
||||
def test_rpg_dashboard_shows_scene_preview_and_forces_jeu2d_on_new_screens(anon_client):
|
||||
_register(anon_client, "rpguser@example.com")
|
||||
_confirm_2fa(anon_client)
|
||||
try:
|
||||
resp = _complete_onboarding(anon_client, name="Mon RPG", onboarding_type="rpg")
|
||||
assert resp.status_code == 302
|
||||
slug = auth.get_user_by_email("rpguser@example.com")["project_slug"]
|
||||
assert resp.headers["Location"] == f"/game/{slug}"
|
||||
assert db.get_onboarding_type(slug) == "rpg"
|
||||
screen_list = screens.list_screens(slug)
|
||||
assert len(screen_list) == 1
|
||||
assert screen_list[0]["kind"] == "jeu_2d"
|
||||
|
||||
html = anon_client.get(f"/game/{slug}").get_data(as_text=True)
|
||||
@@ -88,22 +59,6 @@ def test_rpg_dashboard_shows_scene_preview_and_forces_jeu2d_on_new_screens(anon_
|
||||
_cleanup_project("rpguser@example.com")
|
||||
|
||||
|
||||
def test_custom_keeps_todays_full_dashboard_behavior(anon_client):
|
||||
_register(anon_client, "customuser@example.com")
|
||||
_confirm_2fa(anon_client)
|
||||
try:
|
||||
resp = _complete_onboarding(anon_client, name="Mon jeu complet", onboarding_type="custom")
|
||||
assert resp.status_code == 302
|
||||
|
||||
slug = auth.get_user_by_email("customuser@example.com")["project_slug"]
|
||||
assert resp.headers["Location"] == f"/game/{slug}"
|
||||
|
||||
html = anon_client.get(f"/game/{slug}").get_data(as_text=True)
|
||||
assert "Tableau de bord" in html or "gameNavLink" in html
|
||||
finally:
|
||||
_cleanup_project("customuser@example.com")
|
||||
|
||||
|
||||
def test_onboarding_rejects_unknown_type(anon_client):
|
||||
_register(anon_client, "badtype@example.com")
|
||||
_confirm_2fa(anon_client)
|
||||
@@ -115,18 +70,33 @@ def test_onboarding_rejects_unknown_type(anon_client):
|
||||
_cleanup_project("badtype@example.com")
|
||||
|
||||
|
||||
def test_onboarding_rejects_removed_types(anon_client):
|
||||
"""Les anciens types (quiz/embranchement/custom) ne sont plus des choix
|
||||
valides — voir db/games/game_type_catalog.py, ONBOARDING_TYPES ne
|
||||
contient plus que "rpg"."""
|
||||
_register(anon_client, "removedtype@example.com")
|
||||
_confirm_2fa(anon_client)
|
||||
try:
|
||||
for removed_type in ("quiz", "embranchement", "custom"):
|
||||
resp = anon_client.post("/onboarding", data={"onboarding_type": removed_type, "name": "Test"})
|
||||
assert resp.status_code == 200
|
||||
assert auth.get_user_by_email("removedtype@example.com")["project_slug"] is None
|
||||
finally:
|
||||
_cleanup_project("removedtype@example.com")
|
||||
|
||||
|
||||
def test_revisiting_onboarding_after_completion_redirects_without_recreating(anon_client):
|
||||
_register(anon_client, "revisit@example.com")
|
||||
_confirm_2fa(anon_client)
|
||||
try:
|
||||
first = _complete_onboarding(anon_client, name="Premier jeu", onboarding_type="custom")
|
||||
first = _complete_onboarding(anon_client, name="Premier jeu", onboarding_type="rpg")
|
||||
first_slug = auth.get_user_by_email("revisit@example.com")["project_slug"]
|
||||
|
||||
resp = anon_client.get("/onboarding", follow_redirects=False)
|
||||
assert resp.status_code == 302
|
||||
assert resp.headers["Location"] == first.headers["Location"]
|
||||
|
||||
again = anon_client.post("/onboarding", data={"onboarding_type": "quiz", "name": "Second jeu"}, follow_redirects=False)
|
||||
again = anon_client.post("/onboarding", data={"onboarding_type": "rpg", "name": "Second jeu"}, follow_redirects=False)
|
||||
assert auth.get_user_by_email("revisit@example.com")["project_slug"] == first_slug
|
||||
assert again.headers["Location"] == first.headers["Location"]
|
||||
finally:
|
||||
@@ -138,13 +108,13 @@ def test_admin_is_never_redirected_to_onboarding(client, game):
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
def test_admin_can_create_several_games_of_different_types_via_onboarding(client):
|
||||
def test_admin_can_create_several_games_via_onboarding(client):
|
||||
"""Comportement systématique à CHAQUE création de jeu, pas une
|
||||
formalité réservée à la toute première inscription (voir le plan) —
|
||||
un admin, illimité (pas de project_slug dédié), doit pouvoir repasser
|
||||
par /onboarding autant de fois qu'il crée un nouveau jeu, sans jamais
|
||||
être bloqué/redirigé vers un projet précédent."""
|
||||
resp1 = _complete_onboarding(client, name="Jeu Quiz Admin", onboarding_type="quiz")
|
||||
resp1 = _complete_onboarding(client, name="Jeu RPG Admin 1", onboarding_type="rpg")
|
||||
assert resp1.status_code == 302
|
||||
slug1 = resp1.headers["Location"].split("/game/", 1)[1].split("/", 1)[0]
|
||||
slug2 = None
|
||||
@@ -155,9 +125,9 @@ def test_admin_can_create_several_games_of_different_types_via_onboarding(client
|
||||
# le premier projet créé, la page de choix s'affiche à nouveau.
|
||||
resp = client.get("/onboarding")
|
||||
assert resp.status_code == 200
|
||||
assert "Quiz" in resp.get_data(as_text=True)
|
||||
assert "Créer un jeu 2D ludique" in resp.get_data(as_text=True)
|
||||
|
||||
resp2 = _complete_onboarding(client, name="Jeu RPG Admin", onboarding_type="rpg")
|
||||
resp2 = _complete_onboarding(client, name="Jeu RPG Admin 2", onboarding_type="rpg")
|
||||
assert resp2.status_code == 302
|
||||
slug2 = resp2.headers["Location"].split("/game/", 1)[1].split("/", 1)[0]
|
||||
assert slug2 != slug1
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
"""Tests du panneau à onglets / visibilité mutuellement exclusive (2.3)."""
|
||||
import json
|
||||
import re
|
||||
|
||||
from conftest import play_js_bundle
|
||||
|
||||
|
||||
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 test_activer_onglet_action_node_persists_target_element(client, game):
|
||||
"""Régression 2.3 : le nouveau type d'action doit accepter un élément
|
||||
cible comme "modifier_element", sans propriété/valeur associées (tout
|
||||
est déduit automatiquement au clic côté jouable : montrer CET élément,
|
||||
masquer ses frères)."""
|
||||
screen_id = _create_screen(client, game)
|
||||
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "conteneur"})
|
||||
parent_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
child_ids = []
|
||||
for _ in range(3):
|
||||
resp = client.post(f"/game/{game}/elements/{parent_id}/children/add", data={"widget": "titre"})
|
||||
child_ids.append(int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1)))
|
||||
|
||||
resp = client.post(
|
||||
f"/game/{game}/screens/{screen_id}/flow/nodes/add",
|
||||
data=json.dumps({"node_type": "action", "action_type": "activer_onglet", "target_element_id": child_ids[1]}),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
node = resp.get_json()
|
||||
assert node["action_type"] == "activer_onglet"
|
||||
assert node["target_element_id"] == child_ids[1]
|
||||
|
||||
|
||||
def test_play_page_exposes_activer_onglet_runtime(client, game):
|
||||
"""Le runtime jouable doit savoir exécuter "activer_onglet" (montrer un
|
||||
élément, masquer ses frères) — vérifie que le JS livré au joueur
|
||||
contient bien cette logique, sans dépendre d'un navigateur."""
|
||||
js = play_js_bundle()
|
||||
assert "'activer_onglet'" in js
|
||||
assert "parentKey" in js
|
||||
@@ -1,124 +0,0 @@
|
||||
"""Tests du widget Onglets — ajouter/renommer/supprimer un onglet, contenu
|
||||
par onglet (un vrai conteneur enfant, voir screens/elements/add_tab.py).
|
||||
|
||||
À ne pas confondre avec tests/test_onglets.py, qui teste l'action de flux
|
||||
existante "activer_onglet" (2.3, montrer un élément et masquer ses frères) —
|
||||
un mécanisme manuel plus bas niveau, toujours disponible séparément, que ce
|
||||
widget ne remplace pas."""
|
||||
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 _create_onglets(client, slug, screen_id):
|
||||
resp = client.post(f"/game/{slug}/screens/{screen_id}/elements/add", data={"widget": "onglets"}, follow_redirects=False)
|
||||
return int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
|
||||
|
||||
def _tab_rename_ids(html):
|
||||
"""Ids des formulaires de renommage DANS le bloc "Onglets" du panneau de
|
||||
propriétés — pas le premier /elements/<id>/rename de la page (le champ
|
||||
"Nommer" générique de l'élément actuellement sélectionné), ni une
|
||||
occurrence de "Onglets (" dans une liste déroulante d'éléments de la
|
||||
Logique de la scène (ex. "<option>Onglets (elt-1)</option>"), qui existe
|
||||
aussi sur la page — seul l'en-tête du groupe de réglages ("<summary>🗂️
|
||||
Onglets (") repère sans ambiguïté le bloc de propriétés lui-même."""
|
||||
block = html.split(">🗂️ Onglets (", 1)[-1]
|
||||
return [int(x) for x in re.findall(r"/elements/(\d+)/rename", block)]
|
||||
|
||||
|
||||
def test_new_tabs_widget_has_no_tabs_yet(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
_create_onglets(client, game, screen_id)
|
||||
html = client.get(f"/game/{game}/play").data.decode()
|
||||
assert "forgeTabBtn" not in html
|
||||
assert "ajoute au moins un onglet" in html
|
||||
|
||||
|
||||
def test_add_tab_creates_a_named_child_container(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
onglets_id = _create_onglets(client, game, screen_id)
|
||||
|
||||
resp = client.post(f"/game/{game}/elements/{onglets_id}/tabs/add", follow_redirects=False)
|
||||
assert resp.status_code == 302
|
||||
assert f"selected={onglets_id}" in resp.headers["Location"] # reste sur le widget Onglets, pas le nouvel onglet
|
||||
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
|
||||
assert ">Onglet 1<" in html # nom par défaut, affiché tel quel comme libellé de l'onglet
|
||||
|
||||
|
||||
def test_second_tab_is_named_onglet_2_and_starts_hidden(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
onglets_id = _create_onglets(client, game, screen_id)
|
||||
client.post(f"/game/{game}/elements/{onglets_id}/tabs/add")
|
||||
client.post(f"/game/{game}/elements/{onglets_id}/tabs/add")
|
||||
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
|
||||
assert ">Onglet 1<" in html
|
||||
assert ">Onglet 2<" in html
|
||||
# Seul le premier onglet est visible par défaut (le second est en display:none).
|
||||
assert 'style="display:none;"' in html
|
||||
|
||||
|
||||
def test_renaming_a_tab_changes_the_visible_label(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
onglets_id = _create_onglets(client, game, screen_id)
|
||||
client.post(f"/game/{game}/elements/{onglets_id}/tabs/add")
|
||||
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit?selected={onglets_id}").data.decode()
|
||||
tab_id = _tab_rename_ids(html)[0]
|
||||
|
||||
client.post(f"/game/{game}/elements/{tab_id}/rename", data={"name": "Statistiques"})
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
|
||||
assert ">Statistiques<" in html
|
||||
assert ">Onglet 1<" not in html
|
||||
|
||||
|
||||
def test_deleting_a_tab_removes_it_and_reselects_the_tabs_widget(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
onglets_id = _create_onglets(client, game, screen_id)
|
||||
client.post(f"/game/{game}/elements/{onglets_id}/tabs/add")
|
||||
client.post(f"/game/{game}/elements/{onglets_id}/tabs/add", follow_redirects=False)
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit?selected={onglets_id}").data.decode()
|
||||
tab_ids = _tab_rename_ids(html)
|
||||
assert len(tab_ids) == 2
|
||||
|
||||
resp = client.post(f"/game/{game}/elements/{tab_ids[1]}/delete", follow_redirects=False)
|
||||
assert resp.status_code == 302
|
||||
assert f"selected={onglets_id}" in resp.headers["Location"]
|
||||
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
|
||||
assert html.count("forgeShowTab(this)") == 1
|
||||
|
||||
|
||||
def test_tab_content_holds_a_real_container_children_can_be_added_to(client, game):
|
||||
"""Régression : chaque onglet doit se comporter comme un vrai conteneur
|
||||
— on doit pouvoir y poser un Répéteur/Conteneur/etc. via "Ajouter DANS
|
||||
ce conteneur", exactement comme pour n'importe quel autre conteneur."""
|
||||
screen_id = _create_screen(client, game)
|
||||
onglets_id = _create_onglets(client, game, screen_id)
|
||||
client.post(f"/game/{game}/elements/{onglets_id}/tabs/add")
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit?selected={onglets_id}").data.decode()
|
||||
tab_id = _tab_rename_ids(html)[0]
|
||||
|
||||
resp = client.post(f"/game/{game}/elements/{tab_id}/children/add", data={"widget": "titre"}, follow_redirects=False)
|
||||
assert resp.status_code == 302
|
||||
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": "Bienvenue dans cet onglet"})
|
||||
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
|
||||
assert "Bienvenue dans cet onglet" in html
|
||||
|
||||
|
||||
def test_deleting_the_tabs_widget_cascades_to_its_tabs(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
onglets_id = _create_onglets(client, game, screen_id)
|
||||
client.post(f"/game/{game}/elements/{onglets_id}/tabs/add")
|
||||
|
||||
client.post(f"/game/{game}/elements/{onglets_id}/delete")
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
|
||||
assert "forgeTabBtn" not in html
|
||||
assert ">Onglet 1<" not in html
|
||||
@@ -21,13 +21,27 @@ def _add_personnage(client, slug, screen_id):
|
||||
return int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
|
||||
|
||||
def test_freshly_placed_personnage_defaults_to_the_joueur_role(client, tmp_game_slug_cleanup):
|
||||
"""Comportement inchangé pour un personnage déjà posé avant l'ajout de
|
||||
ce réglage : c'était déjà, de fait, le seul rôle qui existait."""
|
||||
def test_freshly_placed_personnage_defaults_to_the_pnj_role(client, tmp_game_slug_cleanup):
|
||||
"""Demande explicite : un personnage fraîchement posé n'est JAMAIS le
|
||||
joueur par défaut, même le tout premier de la scène — voir
|
||||
screens/scenes/add_scene_object.py, qui enregistre "pnj" explicitement.
|
||||
Passer un personnage en "Joueur" est désormais toujours un choix
|
||||
explicite (menu "🏷️ Rôle" du panneau de propriétés)."""
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
obj_id = _add_personnage(client, slug, screen_id)
|
||||
obj = screens.get_scene_object(slug, obj_id)
|
||||
assert screens.resolve_personnage_role(obj) == "joueur"
|
||||
assert screens.resolve_personnage_role(obj) == "pnj"
|
||||
|
||||
|
||||
def test_second_personnage_also_defaults_to_the_pnj_role(client, tmp_game_slug_cleanup):
|
||||
"""Non-régression : un deuxième personnage ne doit pas non plus
|
||||
retomber sur "joueur" — sinon il se faisait exclure à tort de "🧩
|
||||
Collision" (voir scene_edit_view.py, qui exclut TOUJOURS le joueur)."""
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
_add_personnage(client, slug, screen_id)
|
||||
second_id = _add_personnage(client, slug, screen_id)
|
||||
obj = screens.get_scene_object(slug, second_id)
|
||||
assert screens.resolve_personnage_role(obj) == "pnj"
|
||||
|
||||
|
||||
def test_role_route_persists_a_valid_role(client, tmp_game_slug_cleanup):
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
"""Widget "Personnage" (Phase 8) — refonte visuelle du système de sprites
|
||||
de la Phase 7, à la demande de l'utilisateur : un vrai objet posé sur la
|
||||
scène (galerie visuelle, pas un élément générique piloté depuis la
|
||||
logique de flow), affichant sa pose "idle" dès le rendu SERVEUR. Le
|
||||
moteur d'exécution (runSpriteAnimation, kind="sprite" de la Timeline)
|
||||
reste celui de la Phase 7, inchangé — seul l'auteuring dans l'éditeur est
|
||||
testé ici."""
|
||||
import json
|
||||
import re
|
||||
|
||||
from conftest import screen_edit_js_bundle
|
||||
|
||||
import screens
|
||||
|
||||
|
||||
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_personnage(client, slug, screen_id, forge_character=None):
|
||||
data = {"widget": "personnage"}
|
||||
if forge_character:
|
||||
data["forge_character"] = forge_character
|
||||
resp = client.post(f"/game/{slug}/screens/{screen_id}/elements/add", data=data, follow_redirects=False)
|
||||
return int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
|
||||
|
||||
def test_freshly_created_personnage_defaults_to_forge_character(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
el_id = _add_personnage(client, game, screen_id)
|
||||
el = screens.get_element(game, el_id)
|
||||
data = screens.resolve_personnage_data(el)
|
||||
assert data["source"] == "forge"
|
||||
assert data["forge_character"] in screens.SPRITE_LIBRARY
|
||||
|
||||
|
||||
def test_personnage_renders_idle_frame_server_side_without_js(client, game):
|
||||
"""Confirme l'aperçu correct même sans JS (voir render_personnage.py) :
|
||||
l'élément fraîchement posé montre déjà une vraie image, jamais un <img
|
||||
src=""> cassé qu'il faudrait d'abord configurer."""
|
||||
screen_id = _create_screen(client, game)
|
||||
el_id = _add_personnage(client, game, screen_id, forge_character="zombie")
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit").get_data(as_text=True)
|
||||
assert '/static/characters/zombie/idle.png' in html
|
||||
assert f'data-element-id="{el_id}"' in html
|
||||
assert 'data-personnage="1"' in html
|
||||
|
||||
|
||||
def test_add_personnage_with_specific_forge_character(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
el_id = _add_personnage(client, game, screen_id, forge_character="zombie")
|
||||
el = screens.get_element(game, el_id)
|
||||
data = screens.resolve_personnage_data(el)
|
||||
assert data == {"source": "forge", "forge_character": "zombie"}
|
||||
|
||||
|
||||
def test_unknown_forge_character_falls_back_to_default(client, game):
|
||||
"""L'import de sprites personnalisés est retiré pour le moment (voir
|
||||
element_add.py) : une valeur qui n'est pas une clé de SPRITE_LIBRARY
|
||||
(ex. l'ancienne tuile "__custom__") est ignorée, l'élément garde le
|
||||
personnage Forge par défaut plutôt que de démarrer sans animation."""
|
||||
screen_id = _create_screen(client, game)
|
||||
el_id = _add_personnage(client, game, screen_id, forge_character="__custom__")
|
||||
el = screens.get_element(game, el_id)
|
||||
data = screens.resolve_personnage_data(el)
|
||||
assert data == {"source": "forge", "forge_character": "male-adventurer"}
|
||||
|
||||
|
||||
def test_swap_forge_character_via_ajax_route(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
el_id = _add_personnage(client, game, screen_id, forge_character="male-adventurer")
|
||||
resp = client.post(
|
||||
f"/game/{game}/elements/{el_id}/personnage",
|
||||
data=json.dumps({"source": "forge", "forge_character": "zombie"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.get_json()
|
||||
assert body["animations"]["idle"] == ["/static/characters/zombie/idle.png"]
|
||||
el = screens.get_element(game, el_id)
|
||||
assert screens.resolve_personnage_data(el)["forge_character"] == "zombie"
|
||||
|
||||
|
||||
def test_import_custom_animations_via_ajax_route(client, game):
|
||||
"""La résolution _personnage_data côté serveur gère toujours la source
|
||||
"custom" (voir personnage_data.py) même si aucune UI ne permet plus de
|
||||
la créer (retiré pour le moment) — vérifie que ce chemin reste correct
|
||||
si réintroduit plus tard."""
|
||||
screen_id = _create_screen(client, game)
|
||||
el_id = _add_personnage(client, game, screen_id, forge_character="male-adventurer")
|
||||
custom = {"source": "custom", "animations": {"idle": ["https://x/idle.png"], "marche": ["https://x/w0.png", "https://x/w1.png"]}}
|
||||
resp = client.post(
|
||||
f"/game/{game}/elements/{el_id}/personnage",
|
||||
data=json.dumps(custom),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()["animations"] == custom["animations"]
|
||||
el = screens.get_element(game, el_id)
|
||||
assert screens.resolve_personnage_animations(el) == custom["animations"]
|
||||
|
||||
|
||||
def test_resolve_personnage_idle_frame_falls_back_to_first_animation(client, game):
|
||||
"""Un sprite personnalisé sans animation nommée "idle" (juste "marche"
|
||||
par ex.) doit quand même montrer QUELQUE CHOSE plutôt qu'une image
|
||||
cassée — retombe sur la première animation disponible."""
|
||||
screen_id = _create_screen(client, game)
|
||||
el_id = _add_personnage(client, game, screen_id, forge_character="male-adventurer")
|
||||
client.post(
|
||||
f"/game/{game}/elements/{el_id}/personnage",
|
||||
data=json.dumps({"source": "custom", "animations": {"marche": ["https://x/w0.png"]}}),
|
||||
content_type="application/json",
|
||||
)
|
||||
el = screens.get_element(game, el_id)
|
||||
assert screens.resolve_personnage_idle_frame(el) == "https://x/w0.png"
|
||||
|
||||
|
||||
def test_screen_edit_exposes_personnage_gallery_and_scripts(client, game):
|
||||
"""Les 6 Kenney (screens.PUBLIC_SPRITE_LIBRARY) ont chacun leur tile —
|
||||
les sprites animaux CraftPix (screens.ADMIN_SPRITE_LIBRARY), eux, sont
|
||||
groupés par famille dans cette même galerie (un seul tile par animal,
|
||||
voir screens.sprite_gallery_families et le plan "Bibliothèque de
|
||||
sprites animaux CraftPix"), pas testés ici un par un."""
|
||||
screen_id = _create_screen(client, game)
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit").get_data(as_text=True)
|
||||
for slug in screens.PUBLIC_SPRITE_LIBRARY:
|
||||
assert f"/static/characters/{slug}/idle.png" in html
|
||||
assert "/static/characters/animals/bunny/01/idle0.png" in html
|
||||
assert "personnageGallery" in html
|
||||
# L'import de sprites personnalisés est retiré pour le moment.
|
||||
assert "Sprite personnalisé" not in html
|
||||
js = screen_edit_js_bundle()
|
||||
assert "addPersonnageElement" in js
|
||||
assert "addPersonnageAnimation" not in js
|
||||
|
||||
|
||||
def test_sprite_library_has_all_six_characters_with_all_animations(client, game):
|
||||
"""screens.PUBLIC_SPRITE_LIBRARY (les 6 Kenney, CC0) — distinct de
|
||||
screens.SPRITE_LIBRARY, qui inclut aussi les sprites animaux CraftPix
|
||||
(screens.ADMIN_SPRITE_LIBRARY, réservés au rôle admin, voir le plan
|
||||
"Bibliothèque de sprites animaux CraftPix" et
|
||||
test_admin_sprite_library_loaded_from_manifest)."""
|
||||
assert set(screens.PUBLIC_SPRITE_LIBRARY) == {
|
||||
"male-adventurer", "female-adventurer", "male-person",
|
||||
"female-person", "robot", "zombie",
|
||||
}
|
||||
for character in screens.PUBLIC_SPRITE_LIBRARY.values():
|
||||
names = set(character["animations"])
|
||||
assert {"idle", "walk", "run", "jump", "attack"} <= names
|
||||
assert len(character["animations"]["walk"]) == 8
|
||||
@@ -110,16 +110,6 @@ def test_create_global_var_route_respects_per_player_checkbox(client, game):
|
||||
assert variables["compteur_visites"]["per_player"] == 0
|
||||
|
||||
|
||||
def test_object_new_route_respects_per_player_checkbox(client, game):
|
||||
client.post(f"/game/{game}/objects/new", data={"object_name": "Inventaire"})
|
||||
definitions = {d["name"]: d for d in db.list_definitions(game)}
|
||||
assert definitions["Inventaire"]["per_player"] == 1
|
||||
|
||||
client.post(f"/game/{game}/objects/new", data={"object_name": "Catalogue", "per_player": "0"})
|
||||
definitions = {d["name"]: d for d in db.list_definitions(game)}
|
||||
assert definitions["Catalogue"]["per_player"] == 0
|
||||
|
||||
|
||||
def test_creator_preview_default_matches_shared_definition_behavior(game):
|
||||
"""L'aperçu créateur (aucun player_id passé nulle part, donc
|
||||
PLAYER_SHARED partout) doit continuer de fonctionner exactement comme
|
||||
|
||||
@@ -1,88 +1,47 @@
|
||||
"""Position/déplacement d'élément + condition de collision — Phase 5 du
|
||||
plan d'extension du moteur. Comme le reste du graphe de logique côté
|
||||
client, le comportement RÉEL (déplacement CSS effectif, chevauchement de
|
||||
rectangles via getBoundingClientRect) n'est pas testable sans navigateur
|
||||
(voir play_js_bundle() — on vérifie que la logique existe dans le JS
|
||||
livré et que les nœuds persistent leurs champs, pas qu'ils s'exécutent)."""
|
||||
import json
|
||||
import re
|
||||
|
||||
from conftest import play_js_bundle, screen_edit_js_bundle
|
||||
"""Position/déplacement d'objet de scène — Phase 5 du plan d'extension du
|
||||
moteur, porté sur jeu_2d (voir OBJECT_ACTION_PROPERTIES, screens/scenes/
|
||||
flow_palette.py) depuis l'éditeur document d'origine (retiré). Comme le
|
||||
reste du graphe de logique côté client, le comportement RÉEL (déplacement
|
||||
CSS effectif, chevauchement de rectangles via getBoundingClientRect) n'est
|
||||
pas testable sans navigateur (voir play_js_bundle() — on vérifie que la
|
||||
logique existe dans le JS livré et que les nœuds persistent leurs champs,
|
||||
pas qu'ils s'exécutent). Le nœud de condition de collision entre deux
|
||||
objets de scène (cond_element_a/cond_element_b) est déjà couvert par
|
||||
test_scene_edit_view.py::test_flow_node_collision_condition_targets_scene_objects."""
|
||||
from conftest import play_js_bundle
|
||||
|
||||
import screens
|
||||
|
||||
|
||||
def _create_element(client, slug, widget="conteneur"):
|
||||
resp = client.post(f"/game/{slug}/screens/new", data={"name": "Accueil"}, follow_redirects=False)
|
||||
screen_id = int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1))
|
||||
resp = client.post(f"/game/{slug}/screens/{screen_id}/elements/add", data={"widget": widget}, follow_redirects=False)
|
||||
element_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
return screen_id, element_id
|
||||
def _create_screen_and_object(slug):
|
||||
screen_id = screens.create_screen(slug, "Accueil", kind="jeu_2d")
|
||||
obj_id = screens.add_scene_object(slug, screen_id)
|
||||
return screen_id, obj_id
|
||||
|
||||
|
||||
def _add_flow_node(client, slug, screen_id, **fields):
|
||||
resp = client.post(
|
||||
f"/game/{slug}/screens/{screen_id}/flow/nodes/add",
|
||||
data=json.dumps(fields),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
return resp.get_json()["id"]
|
||||
|
||||
|
||||
def test_position_action_node_persists_absolute_property(client, game):
|
||||
screen_id, element_id = _create_element(client, game)
|
||||
node_id = _add_flow_node(
|
||||
client, game, screen_id, node_type="action", action_type="modifier_element",
|
||||
target_element_id=element_id, element_property="pos_x", element_value="42",
|
||||
def test_position_action_node_persists_absolute_property(game):
|
||||
screen_id, obj_id = _create_screen_and_object(game)
|
||||
node_id = screens.add_flow_node(
|
||||
game, screen_id, node_type="action", action_type="modifier_objet_scene",
|
||||
target_object_id=obj_id, element_property="pos_x", element_value="42",
|
||||
)
|
||||
node = screens.get_flow_node(game, node_id)
|
||||
assert node["element_property"] == "pos_x"
|
||||
assert node["element_value"] == "42"
|
||||
assert node["target_object_id"] == obj_id
|
||||
|
||||
|
||||
def test_position_action_node_persists_relative_property(client, game):
|
||||
screen_id, element_id = _create_element(client, game)
|
||||
node_id = _add_flow_node(
|
||||
client, game, screen_id, node_type="action", action_type="modifier_element",
|
||||
target_element_id=element_id, element_property="pos_y_relatif", element_value="-5",
|
||||
def test_position_action_node_persists_relative_property(game):
|
||||
screen_id, obj_id = _create_screen_and_object(game)
|
||||
node_id = screens.add_flow_node(
|
||||
game, screen_id, node_type="action", action_type="modifier_objet_scene",
|
||||
target_object_id=obj_id, element_property="pos_y_relatif", element_value="-5",
|
||||
)
|
||||
node = screens.get_flow_node(game, node_id)
|
||||
assert node["element_property"] == "pos_y_relatif"
|
||||
assert node["element_value"] == "-5"
|
||||
|
||||
|
||||
def test_collision_condition_node_persists_both_elements(client, game):
|
||||
screen_id, element_a = _create_element(client, game)
|
||||
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "conteneur"}, follow_redirects=False)
|
||||
element_b = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
node_id = _add_flow_node(
|
||||
client, game, screen_id, node_type="condition", cond_source="collision",
|
||||
cond_element_a=element_a, cond_element_b=element_b,
|
||||
)
|
||||
node = screens.get_flow_node(game, node_id)
|
||||
assert node["cond_source"] == "collision"
|
||||
assert node["cond_element_a"] == element_a
|
||||
assert node["cond_element_b"] == element_b
|
||||
assert node["cond_field"] is None
|
||||
assert node["cond_operator"] is None
|
||||
|
||||
|
||||
def test_deleting_element_cleans_up_collision_condition_nodes(client, game):
|
||||
"""Un nœud de collision qui référence un élément supprimé n'a plus de
|
||||
sens (voir delete_element.py) — même nettoyage que pour un déclencheur/
|
||||
une action ciblant un élément supprimé."""
|
||||
screen_id, element_a = _create_element(client, game)
|
||||
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "conteneur"}, follow_redirects=False)
|
||||
element_b = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
node_id = _add_flow_node(
|
||||
client, game, screen_id, node_type="condition", cond_source="collision",
|
||||
cond_element_a=element_a, cond_element_b=element_b,
|
||||
)
|
||||
client.post(f"/game/{game}/elements/{element_a}/delete", follow_redirects=False)
|
||||
assert screens.get_flow_node(game, node_id) is None
|
||||
|
||||
|
||||
def test_play_page_exposes_position_and_collision_runtime(client, game):
|
||||
js = play_js_bundle()
|
||||
assert "pos_x" in js
|
||||
@@ -90,13 +49,3 @@ def test_play_page_exposes_position_and_collision_runtime(client, game):
|
||||
assert "elementsOverlap" in js
|
||||
assert "getBoundingClientRect" in js
|
||||
assert "'collision'" in js
|
||||
|
||||
|
||||
def test_screen_edit_exposes_collision_condition_editor(client, game):
|
||||
html = client.get(f"/game/{game}/screens/{_create_element(client, game)[0]}/edit").get_data(as_text=True)
|
||||
assert "nf-cond-collision-fields" in html
|
||||
assert "nf-cond-element-a" in html
|
||||
assert "nf-cond-element-b" in html
|
||||
js = screen_edit_js_bundle()
|
||||
assert "isCollision" in js
|
||||
assert "cond_element_a" in js
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
"""Régression : un nom de champ qui est un mot réservé SQL (ex. "order")
|
||||
faisait planter TOUTE requête SQL brute qui le mentionnait telle quelle
|
||||
(CREATE TABLE, INSERT, UPDATE, ALTER TABLE...) — ex. "OperationalError:
|
||||
near \"order\": syntax error" dans create_definition(). Comme le crash
|
||||
survient APRÈS l'INSERT de la ligne _definitions mais AVANT le commit(),
|
||||
rien n'était jamais persisté : l'objet entier disparaissait, sans le
|
||||
moindre message d'erreur — vécu comme "le panneau recharge la page sans
|
||||
créer d'objet" même avec des champs parfaitement remplis. Corrigé en
|
||||
encadrant chaque nom de colonne de guillemets doubles (db/quote_ident.py)
|
||||
partout où il est interpolé dans du SQL brut."""
|
||||
import db
|
||||
|
||||
|
||||
def test_object_created_with_a_reserved_keyword_field_name(client, game):
|
||||
resp = client.post(f"/game/{game}/objects/new", data={
|
||||
"object_name": "dialog",
|
||||
"field_name[]": ["order", "spiker", "text"],
|
||||
"field_type[]": ["nombre_entier", "texte", "texte_long"],
|
||||
"field_relation[]": ["", "", ""],
|
||||
"field_required[]": ["1", "1", "1"],
|
||||
"field_min[]": ["1", "", ""],
|
||||
"field_max[]": ["100", "", ""],
|
||||
}, follow_redirects=False)
|
||||
assert resp.status_code == 302
|
||||
|
||||
definitions = db.list_definitions(game)
|
||||
assert len(definitions) == 1
|
||||
definition = db.get_definition(game, definitions[0]["id"])
|
||||
assert [f["name"] for f in definition["fields"]] == ["order", "spiker", "text"]
|
||||
|
||||
# Insertion, lecture ET mise à jour d'une ligne doivent aussi fonctionner
|
||||
# (insert_row.py / update_row.py / list_rows.py mentionnent aussi "order").
|
||||
client.post(f"/game/{game}/objects/{definition['id']}/data/new",
|
||||
data={"order": "5", "spiker": "Bob", "text": "hello"})
|
||||
rows = db.list_rows(game, definition)
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["order"] == 5
|
||||
|
||||
client.post(f"/game/{game}/objects/{definition['id']}/data/{rows[0]['id']}/edit",
|
||||
data={"order": "9", "spiker": "Bob", "text": "hello"})
|
||||
rows = db.list_rows(game, definition)
|
||||
assert rows[0]["order"] == 9
|
||||
|
||||
|
||||
def test_field_renamed_to_and_from_a_reserved_keyword(client, game):
|
||||
resp = client.post(f"/game/{game}/objects/new", data={
|
||||
"object_name": "chose", "field_name[]": ["nom"], "field_type[]": ["texte"],
|
||||
"field_relation[]": [""], "field_required[]": ["0"], "field_min[]": [""], "field_max[]": [""],
|
||||
}, follow_redirects=False)
|
||||
def_id = int(resp.headers["Location"].rstrip("/").split("/")[-1])
|
||||
field_id = db.get_definition(game, def_id)["fields"][0]["id"]
|
||||
|
||||
resp = client.post(f"/game/{game}/objects/{def_id}/fields/{field_id}/edit",
|
||||
data={"field_name": "group", "field_required": "0"}, follow_redirects=False)
|
||||
assert resp.status_code == 302
|
||||
assert db.get_definition(game, def_id)["fields"][0]["name"] == "group"
|
||||
|
||||
client.post(f"/game/{game}/objects/{def_id}/data/new", data={"group": "valeur"})
|
||||
rows = db.list_rows(game, db.get_definition(game, def_id))
|
||||
assert rows[0]["group"] == "valeur"
|
||||
|
||||
|
||||
def test_added_field_with_a_reserved_keyword_name(client, game):
|
||||
resp = client.post(f"/game/{game}/objects/new", data={
|
||||
"object_name": "chose", "field_name[]": ["nom"], "field_type[]": ["texte"],
|
||||
"field_relation[]": [""], "field_required[]": ["0"], "field_min[]": [""], "field_max[]": [""],
|
||||
}, follow_redirects=False)
|
||||
def_id = int(resp.headers["Location"].rstrip("/").split("/")[-1])
|
||||
|
||||
resp = client.post(f"/game/{game}/objects/{def_id}/fields/add",
|
||||
data={"field_name": "select", "field_type": "texte"}, follow_redirects=False)
|
||||
assert resp.status_code == 302
|
||||
names = [f["name"] for f in db.get_definition(game, def_id)["fields"]]
|
||||
assert "select" in names
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Liste "🧩 Collision" à jour en AJAX (voir routes/scenes/
|
||||
scene_collision_rule_objects.py, refreshCollisionRuleObjects() dans
|
||||
static/js/scenes/scene-editor.js) : appelée après ajout/suppression d'un
|
||||
objet de scène ou changement de rôle/nom, pour ne jamais recharger la page
|
||||
alors que cette liste doit refléter le changement (un objet apparaît/
|
||||
disparaît selon son rôle, ou change de libellé affiché)."""
|
||||
import screens
|
||||
|
||||
|
||||
def _create_jeu2d_game(client, tmp_game_slug_cleanup, name="pytest_collision_ajax"):
|
||||
resp = client.post("/games/new", data={"name": name}, follow_redirects=False)
|
||||
slug = tmp_game_slug_cleanup(resp.headers["Location"].rstrip("/").split("/")[-1])
|
||||
screen_id = screens.create_screen(slug, "Scène 1", kind="jeu_2d")
|
||||
return slug, screen_id
|
||||
|
||||
|
||||
def _add_ajax(client, slug, screen_id, **data):
|
||||
return client.post(
|
||||
f"/game/{slug}/screens/{screen_id}/scene-objects/add",
|
||||
data=data, headers={"X-Requested-With": "fetch"},
|
||||
).get_json()
|
||||
|
||||
|
||||
def _list(client, slug, screen_id):
|
||||
resp = client.get(f"/game/{slug}/screens/{screen_id}/collision-rule-objects")
|
||||
assert resp.status_code == 200
|
||||
return resp.get_json()
|
||||
|
||||
|
||||
def test_freshly_added_personnage_is_pnj_in_the_list(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
obj_id = _add_ajax(client, slug, screen_id, kind="personnage")["id"]
|
||||
entries = _list(client, slug, screen_id)
|
||||
assert len(entries) == 1
|
||||
assert entries[0]["id"] == obj_id
|
||||
assert entries[0]["role"] == "pnj"
|
||||
|
||||
|
||||
def test_role_change_to_joueur_removes_it_from_the_list(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
obj_id = _add_ajax(client, slug, screen_id, kind="personnage")["id"]
|
||||
client.post(f"/game/{slug}/scene-objects/{obj_id}/role", data='{"role": "joueur"}', content_type="application/json")
|
||||
assert _list(client, slug, screen_id) == []
|
||||
|
||||
|
||||
def test_role_change_back_to_pnj_readds_it_to_the_list(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
obj_id = _add_ajax(client, slug, screen_id, kind="personnage")["id"]
|
||||
client.post(f"/game/{slug}/scene-objects/{obj_id}/role", data='{"role": "joueur"}', content_type="application/json")
|
||||
client.post(f"/game/{slug}/scene-objects/{obj_id}/role", data='{"role": "ennemie"}', content_type="application/json")
|
||||
entries = _list(client, slug, screen_id)
|
||||
assert len(entries) == 1
|
||||
assert entries[0]["role"] == "ennemie"
|
||||
|
||||
|
||||
def test_name_change_is_reflected_in_the_list(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
obj_id = _add_ajax(client, slug, screen_id, kind="personnage")["id"]
|
||||
client.post(f"/game/{slug}/scene-objects/{obj_id}/name", data='{"name": "Le Sorcier"}', content_type="application/json")
|
||||
entries = _list(client, slug, screen_id)
|
||||
assert entries[0]["name"] == "Le Sorcier"
|
||||
|
||||
|
||||
def test_deleted_object_is_removed_from_the_list(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
obj_id = _add_ajax(client, slug, screen_id, kind="personnage")["id"]
|
||||
client.post(f"/game/{slug}/scene-objects/{obj_id}/delete", headers={"X-Requested-With": "fetch"})
|
||||
assert _list(client, slug, screen_id) == []
|
||||
|
||||
|
||||
def test_decor_has_no_role(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
_add_ajax(client, slug, screen_id, kind="decor")
|
||||
entries = _list(client, slug, screen_id)
|
||||
assert entries[0]["role"] is None
|
||||
@@ -1,14 +1,11 @@
|
||||
"""Vue de l'éditeur de scène 2D (routes/scenes/scene_edit_view.py) —
|
||||
appelée par routes/screens/screen_edit.py selon screen["kind"] (fusion des
|
||||
moteurs, voir screens/screens_repo/ensure_schema.py), RÉUTILISE tel quel
|
||||
le moteur de logique de flow partagé (flow-editor.js/tabs-and-blocks.js/
|
||||
animation-timeline.js)."""
|
||||
appelée par routes/screens/screen_edit.py, l'unique moteur d'écran
|
||||
(jeu_2d)."""
|
||||
import json
|
||||
import re
|
||||
|
||||
import db
|
||||
import screens
|
||||
from conftest import screen_edit_js_bundle
|
||||
|
||||
|
||||
def _create_jeu2d_game(client, tmp_game_slug_cleanup, name="pytest_scene_edit"):
|
||||
@@ -18,22 +15,12 @@ def _create_jeu2d_game(client, tmp_game_slug_cleanup, name="pytest_scene_edit"):
|
||||
return slug, screen_id
|
||||
|
||||
|
||||
def test_document_game_still_uses_generic_editor(client, game):
|
||||
"""Non-régression — critère de succès n°1 du plan : un jeu "document"
|
||||
existant continue de fonctionner à l'identique."""
|
||||
resp = client.post(f"/game/{game}/screens/new", data={"name": "Accueil"}, follow_redirects=False)
|
||||
screen_id = int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1))
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit").get_data(as_text=True)
|
||||
assert "ÉLÉMENTS DE CET ÉCRAN" in html.upper() or "Éléments de cet écran" in html
|
||||
assert "scene-editor.js" not in html
|
||||
|
||||
|
||||
def test_jeu2d_game_dispatches_to_scene_editor(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
html = client.get(f"/game/{slug}/screens/{screen_id}/edit").get_data(as_text=True)
|
||||
assert "Objets de cette scène" in html
|
||||
assert "scene-editor.js" in html
|
||||
assert "🎭 Personnages" in html
|
||||
assert "Personnages" in html
|
||||
|
||||
|
||||
def test_scene_edit_view_renders_with_a_personnage_object_selected(client, tmp_game_slug_cleanup):
|
||||
@@ -160,12 +147,6 @@ def test_render_scene_object_carries_data_anim_target(client, tmp_game_slug_clea
|
||||
assert f'data-anim-target="{obj_id}"' in html
|
||||
|
||||
|
||||
def test_screen_edit_js_bundle_contains_scene_flow_helpers():
|
||||
js = screen_edit_js_bundle()
|
||||
assert "FLOW_TARGETS_OBJECTS" in js
|
||||
assert "toggleFlowObjectPropertyValue" in js
|
||||
|
||||
|
||||
def test_animation_clip_add_route_accepts_a_scene_object(client, tmp_game_slug_cleanup):
|
||||
"""Bug remonté par l'utilisateur : _animation_clips.element_id portait
|
||||
une VRAIE contrainte FK vers _screen_elements — poser un clip de
|
||||
@@ -191,20 +172,6 @@ def test_deleting_scene_object_cleans_up_its_animation_clips(client, tmp_game_sl
|
||||
assert screens.list_animation_clips(slug, screen_id) == []
|
||||
|
||||
|
||||
def test_deleting_element_cleans_up_its_animation_clips(client, game):
|
||||
resp = client.post(f"/game/{game}/screens/new", data={"name": "Accueil"}, follow_redirects=False)
|
||||
screen_id = int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1))
|
||||
el_resp = client.post(
|
||||
f"/game/{game}/screens/{screen_id}/elements/add",
|
||||
data={"widget": "texte"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
element_id = int(re.search(r"selected=(\d+)", el_resp.headers["Location"]).group(1))
|
||||
screens.add_animation_clip(game, screen_id, element_id, kind="animate_css", animate_name="fadeIn")
|
||||
screens.delete_element(game, element_id)
|
||||
assert screens.list_animation_clips(game, screen_id) == []
|
||||
|
||||
|
||||
def test_scene_edit_view_world_matches_scene_when_no_oversized_fond(client, tmp_game_slug_cleanup):
|
||||
"""Pas de "fond" plus grand que la scène -> le monde affiché reste
|
||||
exactement la scène nominale, et le repère "🎥 Champ de la caméra"
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Ajout d'un objet de scène en AJAX (voir routes/scenes/scene_object_add.py,
|
||||
addSceneObject() dans static/js/scenes/scene-editor.js) : la réponse
|
||||
JSON (posée quand l'appelant envoie X-Requested-With: fetch) remplace le
|
||||
rechargement complet de page — sans ça, poser un objet "vidait" un instant
|
||||
l'éditeur de collision/quêtes (tout l'état JS repartait de zéro à chaque
|
||||
ajout)."""
|
||||
import screens
|
||||
|
||||
|
||||
def _create_jeu2d_game(client, tmp_game_slug_cleanup, name="pytest_add_ajax"):
|
||||
resp = client.post("/games/new", data={"name": name}, follow_redirects=False)
|
||||
slug = tmp_game_slug_cleanup(resp.headers["Location"].rstrip("/").split("/")[-1])
|
||||
screen_id = screens.create_screen(slug, "Scène 1", kind="jeu_2d")
|
||||
return slug, screen_id
|
||||
|
||||
|
||||
def _add_ajax(client, slug, screen_id, **data):
|
||||
return client.post(
|
||||
f"/game/{slug}/screens/{screen_id}/scene-objects/add",
|
||||
data=data, headers={"X-Requested-With": "fetch"},
|
||||
)
|
||||
|
||||
|
||||
def test_ajax_add_returns_json_instead_of_redirecting(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
resp = _add_ajax(client, slug, screen_id, kind="personnage")
|
||||
assert resp.status_code == 200
|
||||
body = resp.get_json()
|
||||
assert body["kind"] == "personnage"
|
||||
assert "rendered_html" in body and body["rendered_html"]
|
||||
assert body["animations"]
|
||||
|
||||
|
||||
def test_non_ajax_add_still_redirects(client, tmp_game_slug_cleanup):
|
||||
"""Secours pour tout appelant non-JS (aucun connu, mais gratuit à garder) —
|
||||
comportement inchangé : redirection classique, sans le header AJAX."""
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
resp = client.post(f"/game/{slug}/screens/{screen_id}/scene-objects/add", data={"kind": "personnage"}, follow_redirects=False)
|
||||
assert resp.status_code == 302
|
||||
assert "selected=" in resp.headers["Location"]
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Suppression d'un objet de scène en AJAX (voir routes/scenes/
|
||||
scene_object_delete.py, deleteSceneObject() dans static/js/scenes/
|
||||
scene-editor.js) : même principe que l'ajout (test_scene_object_add_ajax.py)
|
||||
— une réponse JSON quand l'appelant envoie X-Requested-With: fetch, pour
|
||||
ne plus "vider" l'éditeur de collision/quêtes le temps d'un remplacement
|
||||
de <main> par pjax.js (qui interceptait la soumission du formulaire de
|
||||
suppression classique)."""
|
||||
import screens
|
||||
|
||||
|
||||
def _create_jeu2d_game(client, tmp_game_slug_cleanup, name="pytest_delete_ajax"):
|
||||
resp = client.post("/games/new", data={"name": name}, follow_redirects=False)
|
||||
slug = tmp_game_slug_cleanup(resp.headers["Location"].rstrip("/").split("/")[-1])
|
||||
screen_id = screens.create_screen(slug, "Scène 1", kind="jeu_2d")
|
||||
return slug, screen_id
|
||||
|
||||
|
||||
def _add_ajax(client, slug, screen_id, **data):
|
||||
return client.post(
|
||||
f"/game/{slug}/screens/{screen_id}/scene-objects/add",
|
||||
data=data, headers={"X-Requested-With": "fetch"},
|
||||
).get_json()
|
||||
|
||||
|
||||
def test_ajax_delete_returns_json_and_removes_the_object(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
obj_id = _add_ajax(client, slug, screen_id, kind="personnage")["id"]
|
||||
|
||||
resp = client.post(
|
||||
f"/game/{slug}/scene-objects/{obj_id}/delete",
|
||||
headers={"X-Requested-With": "fetch"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json() == {"ok": True, "id": obj_id}
|
||||
assert screens.get_scene_object(slug, obj_id) is None
|
||||
|
||||
|
||||
def test_ajax_delete_404s_for_missing_object(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
resp = client.post(
|
||||
f"/game/{slug}/scene-objects/999999/delete",
|
||||
headers={"X-Requested-With": "fetch"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
assert resp.get_json() == {"ok": False}
|
||||
|
||||
|
||||
def test_non_ajax_delete_still_redirects(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
obj_id = _add_ajax(client, slug, screen_id, kind="personnage")["id"]
|
||||
resp = client.post(f"/game/{slug}/scene-objects/{obj_id}/delete", follow_redirects=False)
|
||||
assert resp.status_code == 302
|
||||
assert screens.get_scene_object(slug, obj_id) is None
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Liste des noms d'objets de scène à jour, en AJAX (voir routes/scenes/
|
||||
scene_object_names_list.py, refreshSceneObjectNames() dans static/js/
|
||||
scenes/scene-editor.js) : SCENE_OBJECT_NAMES_JSON n'est posée qu'une fois
|
||||
au chargement complet de la page — sans cet endpoint, renommer un
|
||||
personnage puis ouvrir la modale de dialogue dans la même session ne
|
||||
faisait jamais apparaître son nouveau nom dans "qui parle"."""
|
||||
import screens
|
||||
|
||||
|
||||
def _create_jeu2d_game(client, tmp_game_slug_cleanup, name="pytest_names_ajax"):
|
||||
resp = client.post("/games/new", data={"name": name}, follow_redirects=False)
|
||||
slug = tmp_game_slug_cleanup(resp.headers["Location"].rstrip("/").split("/")[-1])
|
||||
screen_id = screens.create_screen(slug, "Scène 1", kind="jeu_2d")
|
||||
return slug, screen_id
|
||||
|
||||
|
||||
def _add_ajax(client, slug, screen_id, **data):
|
||||
return client.post(
|
||||
f"/game/{slug}/screens/{screen_id}/scene-objects/add",
|
||||
data=data, headers={"X-Requested-With": "fetch"},
|
||||
).get_json()
|
||||
|
||||
|
||||
def test_named_personnage_appears_in_the_list(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
obj_id = _add_ajax(client, slug, screen_id, kind="personnage")["id"]
|
||||
assert client.get(f"/game/{slug}/scene-object-names").get_json() == []
|
||||
client.post(f"/game/{slug}/scene-objects/{obj_id}/name", data='{"name": "Zed"}', content_type="application/json")
|
||||
assert client.get(f"/game/{slug}/scene-object-names").get_json() == ["Zed"]
|
||||
|
||||
|
||||
def test_unnamed_objects_are_excluded(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
_add_ajax(client, slug, screen_id, kind="personnage")
|
||||
assert client.get(f"/game/{slug}/scene-object-names").get_json() == []
|
||||
@@ -119,10 +119,8 @@ def test_two_players_get_independent_scores(game):
|
||||
assert db.get_score(game)["score"] == 0 # aperçu créateur (PLAYER_SHARED) inchangé
|
||||
|
||||
|
||||
def test_editor_exposes_score_and_status_action_fields(client, game):
|
||||
def test_editor_exposes_score_and_status_action_labels(client, game):
|
||||
screen_id = screens.create_screen(game, "Accueil")
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit").get_data(as_text=True)
|
||||
assert "nf-action-score-fields" in html
|
||||
assert "nf-action-status-fields" in html
|
||||
assert "Modifier le score" in html
|
||||
assert "Définir le statut de la partie" in html
|
||||
assert "statut de la partie" in html
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
"""Tests des corrections suite au retour utilisateur du 21/08 :
|
||||
- le format d'aperçu (Portrait/Paysage/Carré) d'un écran est mémorisé
|
||||
côté serveur, au lieu de repartir sur Portrait à chaque fois que le
|
||||
panneau est redemandé au serveur (ajout/suppression d'élément,
|
||||
changement de sélection) ;
|
||||
- l'échelle (transform:scale) d'un élément posé directement sur un écran
|
||||
vit sur un cadre intermédiaire (.canvasElementInner / .playElementInner),
|
||||
à la taille exacte du cadre de positionnement/animation (.canvasElement /
|
||||
.playElement) mais distinct de lui — ni sur la balise intérieure générée
|
||||
par render_element_html (sinon l'effet restait visuellement "coincé" à
|
||||
l'intérieur d'un cadre immobile), ni sur le cadre extérieur lui-même
|
||||
(sinon ce "transform" entrait en collision avec celui posé par Animate.css
|
||||
pendant une animation, qui l'écrasait) ;
|
||||
- cette même zone de jeu garde la proportion de l'écran en mode Jouer
|
||||
(voir #playFrame dans play.html), pour que les positions/tailles en %
|
||||
ne se déforment pas selon la fenêtre du joueur."""
|
||||
import re
|
||||
|
||||
from conftest import play_js_bundle
|
||||
|
||||
|
||||
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))
|
||||
|
||||
|
||||
def test_screen_defaults_to_portrait(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
|
||||
assert 'aspect-ratio:9/16;' in html
|
||||
assert 'aspectBtn active" data-aspect="9/16"' in html
|
||||
|
||||
|
||||
def test_aspect_choice_persists_across_reload(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
resp = client.post(
|
||||
f"/game/{game}/screens/{screen_id}/aspect",
|
||||
data='{"aspect": "16/9"}',
|
||||
content_type="application/json",
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
|
||||
assert 'aspect-ratio:16/9;' in html
|
||||
assert 'aspectBtn active" data-aspect="16/9"' in html
|
||||
|
||||
|
||||
def test_aspect_choice_survives_element_add_and_delete(client, game):
|
||||
"""Le vrai bug rapporté : l'écran repartait sur Portrait dès qu'on
|
||||
posait ou supprimait un élément, car ces deux actions redemandent le
|
||||
panneau au serveur."""
|
||||
screen_id = _create_screen(client, game)
|
||||
client.post(
|
||||
f"/game/{game}/screens/{screen_id}/aspect",
|
||||
data='{"aspect": "1/1"}',
|
||||
content_type="application/json",
|
||||
)
|
||||
el_id = _add_element(client, game, screen_id, "bouton")
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
|
||||
assert 'aspect-ratio:1/1;' in html
|
||||
|
||||
client.post(f"/game/{game}/elements/{el_id}/delete", follow_redirects=False)
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
|
||||
assert 'aspect-ratio:1/1;' in html
|
||||
|
||||
|
||||
def test_unknown_aspect_value_is_ignored(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
resp = client.post(
|
||||
f"/game/{game}/screens/{screen_id}/aspect",
|
||||
data='{"aspect": "not-a-ratio"}',
|
||||
content_type="application/json",
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
|
||||
assert 'aspect-ratio:9/16;' in html
|
||||
|
||||
|
||||
def test_scale_lives_on_outer_frame_for_top_level_element(client, game):
|
||||
"""Un conteneur posé directement sur l'écran : l'échelle doit être sur
|
||||
le cadre intermédiaire .canvasElementInner, pas sur le cadre extérieur
|
||||
.canvasElement (id="el-<id>", cible d'Animate.css — un "transform" posé
|
||||
là entrerait en collision avec celui d'une animation en cours) ni sur la
|
||||
balise intérieure générée par render_element_html (sinon l'effet restait
|
||||
visuellement clippé à l'intérieur d'un cadre qui, lui, ne grossissait
|
||||
pas)."""
|
||||
screen_id = _create_screen(client, game)
|
||||
el_id = _add_element(client, game, screen_id, "conteneur")
|
||||
client.post(
|
||||
f"/game/{game}/elements/{el_id}/save",
|
||||
data={"ctrl_scale": "1.5"},
|
||||
)
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
|
||||
# Le cadre extérieur (id="el-<id>", cible Animate.css) ne porte PAS le transform...
|
||||
outer_match = re.search(rf'id="el-{el_id}"[^>]*style="([^"]*)"', html)
|
||||
assert outer_match, "cadre .canvasElement introuvable"
|
||||
assert "transform" not in outer_match.group(1)
|
||||
# ...le cadre intermédiaire .canvasElementInner le porte...
|
||||
inner_html = html.split(f'id="el-{el_id}"', 1)[1]
|
||||
canvas_inner_match = re.search(r'class="canvasElementInner" style="([^"]*)"', inner_html)
|
||||
assert canvas_inner_match, "cadre .canvasElementInner introuvable"
|
||||
assert "transform:scale(1.5)" in canvas_inner_match.group(1)
|
||||
# ...et la balise intérieure (générée par render_element_html) ne le
|
||||
# répète pas une seconde fois.
|
||||
content_style_match = re.search(r'class="canvasElementInner"[^>]*>\s*<div style="([^"]*)"', inner_html)
|
||||
assert content_style_match, "balise intérieure introuvable"
|
||||
assert "transform" not in content_style_match.group(1)
|
||||
|
||||
|
||||
def test_scale_stays_on_own_tag_for_nested_element(client, game):
|
||||
"""Un élément posé À L'INTÉRIEUR d'un conteneur n'a pas de cadre séparé
|
||||
— sa propre balise EST déjà tout l'élément, donc l'échelle y reste."""
|
||||
screen_id = _create_screen(client, game)
|
||||
parent_id = _add_element(client, game, screen_id, "conteneur")
|
||||
resp = client.post(f"/game/{game}/elements/{parent_id}/children/add", data={"widget": "bouton"}, follow_redirects=False)
|
||||
child_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
client.post(f"/game/{game}/elements/{child_id}/save", data={"ctrl_scale": "1.3"})
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
|
||||
assert "transform:scale(1.3)" in html
|
||||
|
||||
|
||||
def test_play_payload_includes_preview_aspect_per_screen(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
client.post(
|
||||
f"/game/{game}/screens/{screen_id}/aspect",
|
||||
data='{"aspect": "16/9"}',
|
||||
content_type="application/json",
|
||||
)
|
||||
html = client.get(f"/game/{game}/play").data.decode()
|
||||
assert '"preview_aspect": "16/9"' in html
|
||||
assert 'data-aspect="16/9"' in html
|
||||
|
||||
|
||||
def test_play_page_has_letterboxing_frame(client, game):
|
||||
html = client.get(f"/game/{game}/play").data.decode()
|
||||
assert 'id="playFrame"' in html
|
||||
assert "applyScreenAspect" in play_js_bundle()
|
||||
@@ -1,278 +0,0 @@
|
||||
"""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
|
||||
@@ -1,102 +0,0 @@
|
||||
"""Ombre portée (panneau de propriétés, tout widget) : 6 sous-réglages
|
||||
(décalage X/Y, flou, étendue, couleur, opacité) combinés en une seule
|
||||
valeur CSS box-shadow."""
|
||||
import re
|
||||
|
||||
import screens
|
||||
|
||||
|
||||
def _create_element(client, slug, widget="conteneur"):
|
||||
resp = client.post(f"/game/{slug}/screens/new", data={"name": "Accueil"}, follow_redirects=False)
|
||||
screen_id = int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1))
|
||||
resp = client.post(f"/game/{slug}/screens/{screen_id}/elements/add", data={"widget": widget}, follow_redirects=False)
|
||||
element_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
return screen_id, element_id
|
||||
|
||||
|
||||
def test_freshly_created_element_has_no_shadow_baked_in(client, game):
|
||||
"""Régression : le réglage par défaut du contrôle "shadow" est un dict
|
||||
composite (voir c_shadow.py), pas une valeur CSS directement écrivable
|
||||
— sans une exclusion dédiée dans default_style_for_widget.py, il était
|
||||
écrit TEL QUEL (repr Python d'un dict) dans le style de TOUT élément
|
||||
fraîchement créé, une valeur CSS invalide qui cassait "box-shadow" (et
|
||||
donnait l'impression que "les propriétés ne sont pas prises en compte")
|
||||
sur chaque nouvel élément, quel que soit son widget."""
|
||||
_screen_id, element_id = _create_element(client, game)
|
||||
el = screens.get_element(game, element_id)
|
||||
assert "box-shadow" not in el["style"]
|
||||
|
||||
html = client.get(f"/game/{game}/runtime-payload").get_json()
|
||||
rendered_html = None
|
||||
for s in html["screens"]:
|
||||
for e in s["elements"]:
|
||||
if e["id"] == element_id:
|
||||
rendered_html = e["rendered_html"]
|
||||
assert rendered_html is not None
|
||||
assert "{" not in rendered_html.split("style=")[1].split(">")[0]
|
||||
|
||||
|
||||
def test_already_corrupted_style_does_not_break_the_whole_style_attribute(client, game):
|
||||
"""Filet de sécurité (voir style_string.py) : un élément déjà touché par
|
||||
le bug ci-dessus avant son correctif (un dict resté tel quel dans son
|
||||
style stocké) doit continuer à s'afficher — sans lui, une seule valeur
|
||||
invalide au milieu de l'attribut style pouvait tout casser."""
|
||||
import json
|
||||
|
||||
import db
|
||||
|
||||
_screen_id, element_id = _create_element(client, game, "texte")
|
||||
el = screens.get_element(game, element_id)
|
||||
corrupted_style = dict(el["style"])
|
||||
corrupted_style["box-shadow"] = {"x": 0, "y": 4, "blur": 12, "spread": 0, "color": "#000000", "opacity": 0.4}
|
||||
conn = db.connect(game)
|
||||
conn.execute("UPDATE _screen_elements SET style = ? WHERE id = ?", (json.dumps(corrupted_style), element_id))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
html = client.get(f"/game/{game}/screens/{_screen_id}/edit").data.decode()
|
||||
idx = html.find(f'id="elt-{element_id}"')
|
||||
style_attr = html[max(0, idx - 400):idx]
|
||||
assert "{" not in style_attr.split('style="')[-1]
|
||||
|
||||
|
||||
def test_shadow_controls_appear_in_the_properties_panel(client, game):
|
||||
screen_id, element_id = _create_element(client, game)
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit?selected={element_id}").data.decode()
|
||||
assert "ctrl_box-shadow_x" in html
|
||||
assert "ctrl_box-shadow_color" in html
|
||||
|
||||
|
||||
def test_saving_a_shadow_composes_box_shadow_css(client, game):
|
||||
screen_id, element_id = _create_element(client, game)
|
||||
client.post(f"/game/{game}/elements/{element_id}/save", data={
|
||||
"ctrl_box-shadow_x": "2", "ctrl_box-shadow_y": "6", "ctrl_box-shadow_blur": "20",
|
||||
"ctrl_box-shadow_spread": "3", "ctrl_box-shadow_color": "#ff0000", "ctrl_box-shadow_opacity": "0.5",
|
||||
})
|
||||
el = screens.get_element(game, element_id)
|
||||
assert el["style"]["box-shadow"] == "2px 6px 20px 3px rgba(255, 0, 0, 0.5)"
|
||||
|
||||
|
||||
def test_shadow_value_round_trips_back_into_the_form(client, game):
|
||||
screen_id, element_id = _create_element(client, game)
|
||||
client.post(f"/game/{game}/elements/{element_id}/save", data={
|
||||
"ctrl_box-shadow_x": "2", "ctrl_box-shadow_y": "6", "ctrl_box-shadow_blur": "20",
|
||||
"ctrl_box-shadow_spread": "3", "ctrl_box-shadow_color": "#ff0000", "ctrl_box-shadow_opacity": "0.5",
|
||||
})
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit?selected={element_id}").data.decode()
|
||||
assert 'name="ctrl_box-shadow_x" value="2.0"' in html or 'name="ctrl_box-shadow_x" value="2"' in html
|
||||
assert 'name="ctrl_box-shadow_color" value="#ff0000"' in html
|
||||
|
||||
|
||||
def test_all_zero_offsets_remove_the_shadow(client, game):
|
||||
screen_id, element_id = _create_element(client, game)
|
||||
client.post(f"/game/{game}/elements/{element_id}/save", data={
|
||||
"ctrl_box-shadow_x": "2", "ctrl_box-shadow_y": "6", "ctrl_box-shadow_blur": "20",
|
||||
"ctrl_box-shadow_spread": "3", "ctrl_box-shadow_color": "#ff0000", "ctrl_box-shadow_opacity": "0.5",
|
||||
})
|
||||
client.post(f"/game/{game}/elements/{element_id}/save", data={
|
||||
"ctrl_box-shadow_x": "0", "ctrl_box-shadow_y": "0", "ctrl_box-shadow_blur": "0",
|
||||
"ctrl_box-shadow_spread": "0", "ctrl_box-shadow_color": "#ff0000", "ctrl_box-shadow_opacity": "0.5",
|
||||
})
|
||||
el = screens.get_element(game, element_id)
|
||||
assert "box-shadow" not in el["style"]
|
||||
+11
-8
@@ -7,7 +7,7 @@ les données persistent, pas qu'elles jouent un son)."""
|
||||
import json
|
||||
import re
|
||||
|
||||
from conftest import play_js_bundle, screen_edit_js_bundle
|
||||
from conftest import play_js_bundle
|
||||
|
||||
import screens
|
||||
|
||||
@@ -85,12 +85,15 @@ def test_play_page_exposes_sound_runtime(client, game):
|
||||
assert "new Audio" in js
|
||||
|
||||
|
||||
def test_screen_edit_exposes_sound_editors(client, game):
|
||||
def test_scene_editor_exposes_sound_action(client, game):
|
||||
"""L'UI de réglage de la musique de fond par écran (screen-bg-music-url)
|
||||
et le formulaire générique de nœud d'action (nf-action-sound-fields)
|
||||
n'existaient que dans l'éditeur document, retiré — l'action "Jouer un
|
||||
son" reste disponible pour jeu_2d via l'assistant "+ Action" de
|
||||
l'onglet Collision (voir screens/scenes/flow_palette.py) ; le réglage
|
||||
de musique de fond par écran (screen_set_background_music.py) reste
|
||||
fonctionnel côté backend/API (voir les tests ci-dessus), même sans UI
|
||||
dédiée dans le nouvel éditeur."""
|
||||
screen_id = _create_screen(client, game)
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit").get_data(as_text=True)
|
||||
assert "screen-bg-music-url" in html
|
||||
assert "nf-action-sound-fields" in html
|
||||
assert "nf-action-sound-url" in html
|
||||
js = screen_edit_js_bundle()
|
||||
assert "jouer_son" in js
|
||||
assert "bindScreenBackgroundMusicField" in js
|
||||
assert "jouer_son" in html
|
||||
|
||||
@@ -10,7 +10,7 @@ import json
|
||||
import os
|
||||
import re
|
||||
|
||||
from conftest import play_js_bundle, screen_edit_js_bundle
|
||||
from conftest import play_js_bundle
|
||||
|
||||
import screens
|
||||
|
||||
@@ -24,9 +24,8 @@ def _create_screen(client, slug, name="Accueil"):
|
||||
return int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1))
|
||||
|
||||
|
||||
def _add_element(client, slug, screen_id, widget="image"):
|
||||
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))
|
||||
def _add_element(client, slug, screen_id, forge_character=None):
|
||||
return screens.add_scene_object(slug, screen_id, forge_character=forge_character)
|
||||
|
||||
|
||||
def _add_flow_node(client, slug, screen_id, **fields):
|
||||
@@ -45,11 +44,11 @@ def test_sprite_animation_action_node_persists(client, game):
|
||||
data_value = json.dumps({"frames": ["/static/characters/zombie/walk0.png", "/static/characters/zombie/walk1.png"], "fps": 8, "loop": True})
|
||||
node_id = _add_flow_node(
|
||||
client, game, screen_id, node_type="action", action_type="jouer_animation_sprite",
|
||||
target_element_id=el_id, data_value=data_value,
|
||||
target_object_id=el_id, data_value=data_value,
|
||||
)
|
||||
node = screens.get_flow_node(game, node_id)
|
||||
assert node["action_type"] == "jouer_animation_sprite"
|
||||
assert node["target_element_id"] == el_id
|
||||
assert node["target_object_id"] == el_id
|
||||
assert json.loads(node["data_value"])["fps"] == 8
|
||||
|
||||
|
||||
@@ -57,12 +56,13 @@ def test_orientation_action_node_persists(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
el_id = _add_element(client, game, screen_id)
|
||||
node_id = _add_flow_node(
|
||||
client, game, screen_id, node_type="action", action_type="modifier_element",
|
||||
target_element_id=el_id, element_property="orientation", element_value="gauche",
|
||||
client, game, screen_id, node_type="action", action_type="modifier_objet_scene",
|
||||
target_object_id=el_id, element_property="orientation", element_value="gauche",
|
||||
)
|
||||
node = screens.get_flow_node(game, node_id)
|
||||
assert node["element_property"] == "orientation"
|
||||
assert node["element_value"] == "gauche"
|
||||
assert node["target_object_id"] == el_id
|
||||
|
||||
|
||||
def test_sprite_timeline_clip_persists(client, game):
|
||||
@@ -116,40 +116,20 @@ def test_swapping_forge_character_updates_already_configured_flow_action(client,
|
||||
depuis gameData.personnage_animations, reconstruit à chaque
|
||||
chargement de full_game_payload() à partir du personnage ACTUEL."""
|
||||
screen_id = _create_screen(client, game)
|
||||
resp = client.post(
|
||||
f"/game/{game}/screens/{screen_id}/elements/add",
|
||||
data={"widget": "personnage", "forge_character": "male-adventurer"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
el_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
el_id = screens.add_scene_object(game, screen_id, forge_character="male-adventurer")
|
||||
_add_flow_node(
|
||||
client, game, screen_id, node_type="action", action_type="jouer_animation_sprite",
|
||||
target_element_id=el_id, data_value=json.dumps({"animation": "walk", "fps": 8, "loop": True}),
|
||||
target_object_id=el_id, data_value=json.dumps({"animation": "walk", "fps": 8, "loop": True}),
|
||||
)
|
||||
|
||||
payload = screens.full_game_payload(game)
|
||||
assert payload["personnage_animations"][str(el_id)]["walk"][0].startswith("/static/characters/male-adventurer/")
|
||||
|
||||
client.post(
|
||||
f"/game/{game}/elements/{el_id}/personnage",
|
||||
f"/game/{game}/scene-objects/{el_id}/personnage",
|
||||
data=json.dumps({"source": "forge", "forge_character": "zombie"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
payload = screens.full_game_payload(game)
|
||||
assert payload["personnage_animations"][str(el_id)]["walk"][0].startswith("/static/characters/zombie/")
|
||||
|
||||
|
||||
def test_screen_edit_exposes_sprite_animation_editors(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit").get_data(as_text=True)
|
||||
assert "nf-action-sprite-fields" in html
|
||||
assert "nf-action-sprite-target-element" in html
|
||||
assert "anim-clip-sprite-fields" in html
|
||||
js = screen_edit_js_bundle()
|
||||
assert "jouer_animation_sprite" in js
|
||||
# Phase 8 — refonte visuelle : le sélecteur d'animation est un vrai
|
||||
# sélecteur à miniatures scopé au personnage ciblé, plus un menu
|
||||
# déroulant "Personnage Forge" global (voir test_personnage_widget.py).
|
||||
assert "onSpriteTargetElementChange" in js
|
||||
assert "populateAnimClipSpriteGrid" in js
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
"""Variables globales de type "objet"/"tableau" (db/constants.py) : la
|
||||
valeur est du JSON, lisible via un chemin ".champ"/"[index]" chaînable
|
||||
(_resolve_variable_path, screens/rendering/filter_repeater_rows.py) —
|
||||
aussi bien dans un filtre de Répéteur ({{$var.chemin}}) que dans une
|
||||
Condition de visibilité en mode "variable" (contrôle séparé
|
||||
"visibilite_cond_variable_chemin")."""
|
||||
import json
|
||||
import re
|
||||
|
||||
import db
|
||||
|
||||
|
||||
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="texte"):
|
||||
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))
|
||||
|
||||
|
||||
def _rendered_html(client, slug):
|
||||
payload = client.get(f"/game/{slug}/runtime-payload").get_json()
|
||||
return payload["screens"][0]["elements"][0]["rendered_html"]
|
||||
|
||||
|
||||
def _create_niveau_object(client, slug):
|
||||
resp = client.post(f"/game/{slug}/objects/new", data={
|
||||
"object_name": "Niveau", "field_name[]": ["numero"], "field_type[]": ["nombre_entier"],
|
||||
"field_relation[]": [""], "field_required[]": ["0"], "field_min[]": [""], "field_max[]": [""],
|
||||
}, follow_redirects=False)
|
||||
def_id = int(resp.headers["Location"].rstrip("/").split("/")[-1])
|
||||
client.post(f"/game/{slug}/objects/{def_id}/data/new", data={"numero": "12"})
|
||||
client.post(f"/game/{slug}/objects/{def_id}/data/new", data={"numero": "99"})
|
||||
return def_id
|
||||
|
||||
|
||||
def test_create_and_update_validate_json_with_safe_fallback(client, game):
|
||||
client.post(f"/game/{game}/variables/new", data={"name": "perso", "type": "objet", "default_value": "not json"})
|
||||
variable = db.get_global_variable(game, "perso")
|
||||
assert variable["value"] == "{}" # JSON invalide -> défaut sûr, pas la chaîne brute
|
||||
|
||||
client.post(f"/game/{game}/variables/new", data={"name": "scores", "type": "tableau", "default_value": ""})
|
||||
variable = db.get_global_variable(game, "scores")
|
||||
assert variable["value"] == "[]" # vide -> défaut sûr propre au type
|
||||
|
||||
db.update_global_variable(game, db.get_global_variable(game, "perso")["id"], "objet", json.dumps({"nom": "Alice"}))
|
||||
assert json.loads(db.get_global_variable(game, "perso")["value"]) == {"nom": "Alice"}
|
||||
|
||||
|
||||
def test_repeater_filter_reads_a_field_inside_an_object_variable(client, game):
|
||||
client.post(f"/game/{game}/variables/new", data={
|
||||
"name": "perso", "type": "objet",
|
||||
"default_value": json.dumps({"nom": "Alice", "arme": {"degats": 12}}),
|
||||
})
|
||||
def_id = _create_niveau_object(client, game)
|
||||
screen_id = _create_screen(client, game)
|
||||
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "repeteur_donnees"}, follow_redirects=False)
|
||||
rep_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
|
||||
# {{$perso.arme.degats}} (chemin chaîné) doit résoudre à 12, et donc ne
|
||||
# garder que la ligne "numero = 12".
|
||||
client.post(f"/game/{game}/elements/{rep_id}/save", data={
|
||||
"ctrl_definition_id": str(def_id),
|
||||
"ctrl_filtre_champ": "numero",
|
||||
"ctrl_filtre_operateur": "egal",
|
||||
"ctrl_filtre_valeur": "{{$perso.arme.degats}}",
|
||||
})
|
||||
html = _rendered_html(client, game)
|
||||
assert html.count("repeaterItem") == 1
|
||||
assert 'data-row-id="1"' in html
|
||||
assert 'data-row-id="2"' not in html
|
||||
|
||||
|
||||
def test_visibility_condition_reads_an_array_element_by_index(client, game):
|
||||
client.post(f"/game/{game}/variables/new", data={
|
||||
"name": "scores", "type": "tableau", "default_value": json.dumps([10, 20, 30]),
|
||||
})
|
||||
screen_id = _create_screen(client, game)
|
||||
element_id = _add_element(client, game, screen_id)
|
||||
|
||||
# scores[1] == 20 -> visible.
|
||||
client.post(f"/game/{game}/elements/{element_id}/save", data={
|
||||
"ctrl_visibilite_cond_mode": "variable",
|
||||
"ctrl_visibilite_cond_variable": "scores",
|
||||
"ctrl_visibilite_cond_variable_chemin": "[1]",
|
||||
"ctrl_visibilite_cond_operateur": "egal",
|
||||
"ctrl_visibilite_cond_valeur": "20",
|
||||
})
|
||||
html = _rendered_html(client, game)
|
||||
assert "display:none" not in html
|
||||
|
||||
# scores[0] == 10, pas 20 -> masqué.
|
||||
client.post(f"/game/{game}/elements/{element_id}/save", data={
|
||||
"ctrl_visibilite_cond_mode": "variable",
|
||||
"ctrl_visibilite_cond_variable": "scores",
|
||||
"ctrl_visibilite_cond_variable_chemin": "[0]",
|
||||
"ctrl_visibilite_cond_operateur": "egal",
|
||||
"ctrl_visibilite_cond_valeur": "20",
|
||||
})
|
||||
html = _rendered_html(client, game)
|
||||
assert "display:none" in html
|
||||
|
||||
|
||||
def test_invalid_or_missing_path_never_crashes(client, game):
|
||||
client.post(f"/game/{game}/variables/new", data={
|
||||
"name": "perso", "type": "objet", "default_value": json.dumps({"nom": "Alice"}),
|
||||
})
|
||||
screen_id = _create_screen(client, game)
|
||||
element_id = _add_element(client, game, screen_id)
|
||||
client.post(f"/game/{game}/elements/{element_id}/save", data={
|
||||
"ctrl_visibilite_cond_mode": "variable",
|
||||
"ctrl_visibilite_cond_variable": "perso",
|
||||
"ctrl_visibilite_cond_variable_chemin": ".champ_inexistant",
|
||||
"ctrl_visibilite_cond_operateur": "egal",
|
||||
"ctrl_visibilite_cond_valeur": "peu importe",
|
||||
})
|
||||
resp = client.get(f"/game/{game}/runtime-payload")
|
||||
assert resp.status_code == 200
|
||||
assert "display:none" in resp.get_json()["screens"][0]["elements"][0]["rendered_html"]
|
||||
@@ -1,181 +0,0 @@
|
||||
"""Tests de la "condition de visibilité" (panneau de propriétés, tout
|
||||
widget) : masque un élément en mode jouable selon une variable globale ou
|
||||
le champ d'un objet de données. Jamais appliquée dans l'éditeur."""
|
||||
import re
|
||||
|
||||
import db
|
||||
import screens
|
||||
|
||||
|
||||
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="texte"):
|
||||
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))
|
||||
|
||||
|
||||
def _rendered_html(client, slug):
|
||||
payload = client.get(f"/game/{slug}/runtime-payload").get_json()
|
||||
return payload["screens"][0]["elements"][0]["rendered_html"]
|
||||
|
||||
|
||||
def test_variable_mode_hides_and_shows_element(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
element_id = _add_element(client, game, screen_id)
|
||||
client.post(f"/game/{game}/variables/new", data={"name": "porte_ouverte", "type": "booleen", "default_value": "0"})
|
||||
client.post(f"/game/{game}/elements/{element_id}/save", data={
|
||||
"ctrl_visibilite_cond_mode": "variable",
|
||||
"ctrl_visibilite_cond_variable": "porte_ouverte",
|
||||
"ctrl_visibilite_cond_operateur": "egal",
|
||||
"ctrl_visibilite_cond_valeur": "1",
|
||||
})
|
||||
|
||||
html = _rendered_html(client, game)
|
||||
assert "display:none" in html
|
||||
|
||||
db.update_global_variable_value(game, "porte_ouverte", "1")
|
||||
html = _rendered_html(client, game)
|
||||
assert "display:none" not in html
|
||||
|
||||
|
||||
def test_visibility_condition_never_hides_in_the_editor(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
element_id = _add_element(client, game, screen_id)
|
||||
client.post(f"/game/{game}/variables/new", data={"name": "porte_ouverte", "type": "booleen", "default_value": "0"})
|
||||
client.post(f"/game/{game}/elements/{element_id}/save", data={
|
||||
"ctrl_visibilite_cond_mode": "variable",
|
||||
"ctrl_visibilite_cond_variable": "porte_ouverte",
|
||||
"ctrl_visibilite_cond_operateur": "egal",
|
||||
"ctrl_visibilite_cond_valeur": "1",
|
||||
})
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
|
||||
assert 'class="visibilityGated"' not in html
|
||||
assert f'id="el-{element_id}"' in html
|
||||
|
||||
|
||||
def test_objet_mode_uses_most_recent_row_outside_a_repeater(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
element_id = _add_element(client, game, screen_id)
|
||||
resp = client.post(f"/game/{game}/objects/new", data={
|
||||
"object_name": "Partie", "field_name[]": ["score"], "field_type[]": ["nombre_entier"],
|
||||
"field_relation[]": [""], "field_required[]": ["0"], "field_min[]": [""], "field_max[]": [""],
|
||||
}, follow_redirects=False)
|
||||
def_id = int(resp.headers["Location"].rstrip("/").split("/")[-1])
|
||||
client.post(f"/game/{game}/objects/{def_id}/data/new", data={"score": "3"})
|
||||
|
||||
client.post(f"/game/{game}/elements/{element_id}/save", data={
|
||||
"ctrl_visibilite_cond_mode": "objet",
|
||||
"ctrl_visibilite_cond_definition_id": str(def_id),
|
||||
"ctrl_visibilite_cond_champ": "score",
|
||||
"ctrl_visibilite_cond_operateur": "superieur",
|
||||
"ctrl_visibilite_cond_valeur": "10",
|
||||
})
|
||||
assert "display:none" in _rendered_html(client, game)
|
||||
|
||||
client.post(f"/game/{game}/objects/{def_id}/data/1/edit", data={"score": "20"})
|
||||
assert "display:none" not in _rendered_html(client, game)
|
||||
|
||||
|
||||
def test_objet_mode_recognizes_oui_non_as_boolean_literal(client, game):
|
||||
"""Régression : le sélecteur "Valeur fixe" du panneau de propriétés
|
||||
n'offre pas "vrai"/"faux" pour un champ booléen — "Oui"/"Non" est le
|
||||
seul vocabulaire visible dans toute l'app (voir data_list.html, qui
|
||||
affiche "Oui"/"Non" pour ce type de champ). Une condition réglée
|
||||
"est égal à Oui" doit donc reconnaître "Oui" comme vrai, pas seulement
|
||||
"1"/"true"/"vrai" — sinon elle ne s'affichait jamais quand elle aurait
|
||||
dû (repérée avec une "Mail card" affichant les DEUX variantes, ouverte
|
||||
ET fermée, au lieu d'une seule)."""
|
||||
screen_id = _create_screen(client, game)
|
||||
closed_id = _add_element(client, game, screen_id, "icone")
|
||||
resp = client.post(f"/game/{game}/objects/new", data={
|
||||
"object_name": "Email", "field_name[]": ["is_opened"], "field_type[]": ["booleen"],
|
||||
"field_relation[]": [""], "field_required[]": ["0"], "field_min[]": [""], "field_max[]": [""],
|
||||
}, follow_redirects=False)
|
||||
def_id = int(resp.headers["Location"].rstrip("/").split("/")[-1])
|
||||
client.post(f"/game/{game}/objects/{def_id}/data/new", data={"is_opened": "on"})
|
||||
|
||||
client.post(f"/game/{game}/elements/{closed_id}/save", data={
|
||||
"ctrl_visibilite_cond_mode": "objet", "ctrl_visibilite_cond_definition_id": str(def_id),
|
||||
"ctrl_visibilite_cond_champ": "is_opened", "ctrl_visibilite_cond_operateur": "egal", "ctrl_visibilite_cond_valeur": "Non",
|
||||
})
|
||||
assert "display:none" in _rendered_html(client, game)
|
||||
|
||||
open_id = _add_element(client, game, screen_id, "icone")
|
||||
client.post(f"/game/{game}/elements/{open_id}/save", data={
|
||||
"ctrl_visibilite_cond_mode": "objet", "ctrl_visibilite_cond_definition_id": str(def_id),
|
||||
"ctrl_visibilite_cond_champ": "is_opened", "ctrl_visibilite_cond_operateur": "egal", "ctrl_visibilite_cond_valeur": "Oui",
|
||||
})
|
||||
payload = client.get(f"/game/{game}/runtime-payload").get_json()
|
||||
els = {el["id"]: el["rendered_html"] for el in payload["screens"][0]["elements"]}
|
||||
assert "display:none" in els[closed_id]
|
||||
assert "display:none" not in els[open_id]
|
||||
|
||||
|
||||
def test_visible_special_render_widget_carries_refresh_marker(client, game):
|
||||
"""Régression : une icône (special_render, pas le chemin générique)
|
||||
actuellement VISIBLE sous condition doit quand même porter le
|
||||
marqueur "visibilityGated" — sinon refreshRuntimeData() (play.html) ne
|
||||
sait jamais qu'il faut la réévaluer après une action "Modifier une
|
||||
donnée/variable", et elle reste figée dans son état précédent. Cas
|
||||
concret remonté : une icône "enveloppe fermée" (visible tant que
|
||||
is_opened=0) reste affichée EN MÊME TEMPS que l'icône "enveloppe
|
||||
ouverte" (qui, elle, se met à jour normalement puisqu'un élément
|
||||
CACHÉ porte toujours son marqueur via son placeholder)."""
|
||||
screen_id = _create_screen(client, game)
|
||||
element_id = _add_element(client, game, screen_id, "icone")
|
||||
client.post(f"/game/{game}/variables/new", data={"name": "porte_ouverte", "type": "booleen", "default_value": "0"})
|
||||
client.post(f"/game/{game}/elements/{element_id}/save", data={
|
||||
"ctrl_visibilite_cond_mode": "variable",
|
||||
"ctrl_visibilite_cond_variable": "porte_ouverte",
|
||||
"ctrl_visibilite_cond_operateur": "egal",
|
||||
"ctrl_visibilite_cond_valeur": "0",
|
||||
})
|
||||
html = _rendered_html(client, game)
|
||||
assert "display:none" not in html
|
||||
assert "visibilityGated" in html
|
||||
|
||||
|
||||
def test_objet_mode_inside_a_repeater_evaluates_each_row_independently(client, game):
|
||||
"""Régression : une "Mail card" (élément de jeu réutilisable posé dans
|
||||
un Répéteur d'e-mails) doit afficher l'icône "ouvert"/"fermé" qui
|
||||
correspond à SA PROPRE ligne — pas toujours la même ligne (la plus
|
||||
récente de l'objet) pour tous les exemplaires du Répéteur."""
|
||||
resp = client.post(f"/game/{game}/objects/new", data={
|
||||
"object_name": "Email", "field_name[]": ["sujet", "is_opened"], "field_type[]": ["texte", "booleen"],
|
||||
"field_relation[]": ["", ""], "field_required[]": ["0", "0"], "field_min[]": ["", ""], "field_max[]": ["", ""],
|
||||
}, follow_redirects=False)
|
||||
def_id = int(resp.headers["Location"].rstrip("/").split("/")[-1])
|
||||
client.post(f"/game/{game}/objects/{def_id}/data/new", data={"sujet": "Fermé"})
|
||||
client.post(f"/game/{game}/objects/{def_id}/data/new", data={"sujet": "Ouvert", "is_opened": "on"})
|
||||
|
||||
resp = client.post(f"/game/{game}/element-types", data={"name": "Mail card", "icon": "📧"}, follow_redirects=False)
|
||||
et = next(t for t in screens.list_element_types(game) if t["name"] == "Mail card")
|
||||
template_screen_id = et["template_screen_id"]
|
||||
|
||||
closed_icon_id = _add_element(client, game, template_screen_id, "icone")
|
||||
client.post(f"/game/{game}/elements/{closed_icon_id}/save", data={
|
||||
"ctrl_visibilite_cond_mode": "objet", "ctrl_visibilite_cond_definition_id": str(def_id),
|
||||
"ctrl_visibilite_cond_champ": "is_opened", "ctrl_visibilite_cond_operateur": "egal", "ctrl_visibilite_cond_valeur": "0",
|
||||
})
|
||||
open_icon_id = _add_element(client, game, template_screen_id, "icone")
|
||||
client.post(f"/game/{game}/elements/{open_icon_id}/save", data={
|
||||
"ctrl_visibilite_cond_mode": "objet", "ctrl_visibilite_cond_definition_id": str(def_id),
|
||||
"ctrl_visibilite_cond_champ": "is_opened", "ctrl_visibilite_cond_operateur": "egal", "ctrl_visibilite_cond_valeur": "1",
|
||||
})
|
||||
|
||||
screen_id = _create_screen(client, game, "Boîte de réception")
|
||||
repeater_id = _add_element(client, game, screen_id, "repeteur_donnees")
|
||||
client.post(f"/game/{game}/elements/{repeater_id}/save", data={
|
||||
"ctrl_definition_id": str(def_id), "ctrl_template_type_id": str(et["id"]),
|
||||
})
|
||||
|
||||
html = _rendered_html(client, game)
|
||||
items = html.split('class="repeaterItem"')
|
||||
assert len(items) == 3
|
||||
row_closed, row_open = items[1], items[2]
|
||||
assert row_closed.count('class="visibilityGated"') == 1
|
||||
assert row_open.count('class="visibilityGated"') == 1
|
||||
assert row_closed != row_open
|
||||
@@ -1,98 +0,0 @@
|
||||
"""Régression : après une action "Modifier une donnée" qui fait BASCULER
|
||||
une condition de visibilité (ex. une icône "enveloppe fermée" visible
|
||||
quand is_opened=faux, une "enveloppe ouverte" visible quand is_opened=vrai),
|
||||
templates/play.html::refreshRuntimeData() ne retrouvait, dans le HTML
|
||||
fraîchement régénéré, que l'élément qui REDEVIENT visible (repéré via le
|
||||
commentaire "<!--visibilityGated-->" posé après son contenu réel par
|
||||
_mark(), voir render_element_html.py) — jamais celui qui REDEVIENT
|
||||
CACHÉ, dont le placeholder ("<div class=\"visibilityGated\" ...
|
||||
style=\"display:none;\"></div>") n'a pas de commentaire à sa suite, juste
|
||||
une classe CSS sur lui-même. Résultat : l'ancien élément visible restait
|
||||
affiché indéfiniment à côté du nouveau qui apparaît en plus (les deux
|
||||
enveloppes en même temps).
|
||||
|
||||
Ces tests-ci ne peuvent pas simuler le DOM d'un navigateur (refreshRuntimeData
|
||||
est du JS) — ils verrouillent seulement le CONTRAT côté serveur dont
|
||||
dépend le correctif JS (findCommentMarkedDescendants, voir play.html) :
|
||||
un élément CACHÉ par condition de visibilité doit porter la classe
|
||||
"visibilityGated" directement sur sa balise, un élément VISIBLE sous
|
||||
condition doit porter le commentaire juste après. Voir aussi
|
||||
tests/test_data_binding_refresh_marker.py pour le marqueur "dataBound"."""
|
||||
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_icon_with_visibility(client, slug, screen_id, def_id, field_name, expected):
|
||||
resp = client.post(f"/game/{slug}/screens/{screen_id}/elements/add", data={"widget": "icone"}, follow_redirects=False)
|
||||
el_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
client.post(f"/game/{slug}/elements/{el_id}/save", data={
|
||||
"ctrl_visibilite_cond_mode": "objet",
|
||||
"ctrl_visibilite_cond_definition_id": str(def_id),
|
||||
"ctrl_visibilite_cond_champ": field_name,
|
||||
"ctrl_visibilite_cond_operateur": "egal",
|
||||
"ctrl_visibilite_cond_valeur": expected,
|
||||
})
|
||||
return el_id
|
||||
|
||||
|
||||
def test_hidden_element_carries_the_class_marker_not_a_comment(client, game):
|
||||
resp = client.post(f"/game/{game}/objects/new", data={
|
||||
"object_name": "Email", "field_name[]": ["is_opened"],
|
||||
"field_type[]": ["booleen"], "field_relation[]": [""], "field_required[]": ["0"],
|
||||
"field_min[]": [""], "field_max[]": [""],
|
||||
}, follow_redirects=False)
|
||||
def_id = int(resp.headers["Location"].rstrip("/").split("/")[-1])
|
||||
client.post(f"/game/{game}/objects/{def_id}/data/new", data={"is_opened": "0"})
|
||||
|
||||
screen_id = _create_screen(client, game)
|
||||
closed_id = _add_icon_with_visibility(client, game, screen_id, def_id, "is_opened", "Non")
|
||||
open_id = _add_icon_with_visibility(client, game, screen_id, def_id, "is_opened", "Oui")
|
||||
|
||||
payload = client.get(f"/game/{game}/runtime-payload").get_json()
|
||||
elements = {e["id"]: e for e in payload["screens"][0]["elements"]}
|
||||
|
||||
closed_html = elements[closed_id]["rendered_html"]
|
||||
open_html = elements[open_id]["rendered_html"]
|
||||
|
||||
# is_opened=faux : l'enveloppe "fermée" est visible (marqueur en
|
||||
# commentaire, après son vrai contenu) ; l'enveloppe "ouverte" est
|
||||
# cachée (marqueur en CLASSE, directement sur le placeholder).
|
||||
assert "<!--visibilityGated-->" in closed_html
|
||||
assert f'data-element-id="{closed_id}"' in closed_html
|
||||
assert "display:none" not in closed_html
|
||||
|
||||
assert 'class="visibilityGated"' in open_html
|
||||
assert "display:none" in open_html
|
||||
assert f'data-element-id="{open_id}"' in open_html
|
||||
assert "<!--visibilityGated-->" not in open_html
|
||||
|
||||
|
||||
def test_toggling_the_field_swaps_which_element_carries_which_marker(client, game):
|
||||
resp = client.post(f"/game/{game}/objects/new", data={
|
||||
"object_name": "Email2", "field_name[]": ["is_opened"],
|
||||
"field_type[]": ["booleen"], "field_relation[]": [""], "field_required[]": ["0"],
|
||||
"field_min[]": [""], "field_max[]": [""],
|
||||
}, follow_redirects=False)
|
||||
def_id = int(resp.headers["Location"].rstrip("/").split("/")[-1])
|
||||
resp = client.post(f"/game/{game}/objects/{def_id}/data/new", data={"is_opened": "0"}, follow_redirects=False)
|
||||
|
||||
screen_id = _create_screen(client, game)
|
||||
closed_id = _add_icon_with_visibility(client, game, screen_id, def_id, "is_opened", "Non")
|
||||
open_id = _add_icon_with_visibility(client, game, screen_id, def_id, "is_opened", "Oui")
|
||||
|
||||
import db
|
||||
rows = db.list_rows(game, db.get_definition(game, def_id))
|
||||
row_id = rows[0]["id"]
|
||||
db.update_row(game, db.get_definition(game, def_id), row_id, {"is_opened": "on"})
|
||||
|
||||
payload = client.get(f"/game/{game}/runtime-payload").get_json()
|
||||
elements = {e["id"]: e for e in payload["screens"][0]["elements"]}
|
||||
|
||||
# is_opened=vrai maintenant : les rôles s'inversent complètement, aucun
|
||||
# des deux ne doit garder son ancien marqueur.
|
||||
assert 'class="visibilityGated"' in elements[closed_id]["rendered_html"]
|
||||
assert "<!--visibilityGated-->" in elements[open_id]["rendered_html"]
|
||||
Reference in New Issue
Block a user