Supprime le type d'écran "document" et recentre le produit sur le jeu 2D
Build and deploy / test-python (push) Successful in 9m41s
Build and deploy / test-js (push) Successful in 1m0s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped

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:
william
2026-09-04 20:56:32 +02:00
parent 00f7191a8b
commit 50835a18e2
221 changed files with 1239 additions and 12245 deletions
+6 -17
View File
@@ -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}
+1 -13
View File
@@ -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):
-40
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+7 -2
View File
@@ -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)
+2 -29
View File
@@ -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
+8 -11
View File
@@ -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
-474
View File
@@ -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()
+6 -9
View File
@@ -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):
-150
View File
@@ -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
-100
View File
@@ -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"]
-42
View File
@@ -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
-121
View File
@@ -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"])
-104
View File
@@ -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(&#x27;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
View File
@@ -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
View File
@@ -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"]
+4 -20
View File
@@ -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
View File
@@ -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"
-272
View File
@@ -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
+1 -10
View File
@@ -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
-71
View File
@@ -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
View File
@@ -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
-43
View File
@@ -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
-124
View File
@@ -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
+18 -4
View File
@@ -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):
-151
View File
@@ -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
-10
View File
@@ -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
+26 -77
View File
@@ -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
+3 -36
View File
@@ -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"
+40
View File
@@ -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"]
+53
View File
@@ -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
+35
View File
@@ -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() == []
+2 -4
View File
@@ -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
-142
View File
@@ -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()
-278
View File
@@ -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
-102
View File
@@ -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
View File
@@ -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
+11 -31
View File
@@ -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
-121
View File
@@ -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"]
-181
View File
@@ -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
-98
View File
@@ -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"]