Files
Forge-Engine/tests/test_scene_edit_view.py
T
williamandClaude Sonnet 5 55c6bb330b
Build and deploy / test-python (push) Failing after 1m13s
Build and deploy / test-js (push) Successful in 53s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Contrôle direct de la taille de la caméra (Largeur × Hauteur, toujours modifiable)
Remplace le bouton conditionnel "Agrandir la caméra à la zone visible"
(n'apparaissait que si un fond dépassait déjà la scène) par deux champs
"Caméra (px)" TOUJOURS visibles, pré-remplis avec scene_width/
scene_height actuels — demande explicite : "l'écran de l'éditeur a une
taille fixe, la caméra a la même taille, c'est tout", un réglage direct
plutôt qu'une suggestion automatique liée à la taille d'un fond.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 17:28:03 +02:00

286 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)."""
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"):
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 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
def test_scene_edit_view_renders_with_a_personnage_object_selected(client, tmp_game_slug_cleanup):
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", "forge_character": "zombie"},
follow_redirects=False,
)
assert resp.status_code == 302
obj_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
html = client.get(f"/game/{slug}/screens/{screen_id}/edit?selected={obj_id}").get_data(as_text=True)
assert "/static/characters/zombie/idle.png" in html
assert f'data-object-id="{obj_id}"' in html
assert "Personnage Forge" in html
def test_scene_object_geometry_route_persists_pixels(client, tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id)
resp = client.post(
f"/game/{slug}/scene-objects/{obj_id}/geometry",
data=json.dumps({"x": 111, "y": 222, "width": 48, "height": 96}),
content_type="application/json",
)
assert resp.status_code == 200
obj = screens.get_scene_object(slug, obj_id)
assert (obj["x"], obj["y"], obj["width"], obj["height"]) == (111, 222, 48, 96)
def test_scene_object_delete_route(client, tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_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
def test_flow_node_targets_scene_object(client, tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, forge_character="zombie")
resp = client.post(
f"/game/{slug}/screens/{screen_id}/flow/nodes/add",
data=json.dumps({
"node_type": "action", "action_type": "jouer_animation_sprite",
"target_object_id": obj_id, "data_value": json.dumps({"animation": "walk", "fps": 8, "loop": True}),
}),
content_type="application/json",
)
assert resp.status_code == 200
node_id = resp.get_json()["id"]
node = screens.get_flow_node(slug, node_id)
assert node["target_object_id"] == obj_id
assert node["target_element_id"] is None
def test_held_key_trigger_persists_and_is_in_scene_palette(client, tmp_game_slug_cleanup):
"""Mouvement continu (touche maintenue) — répond au manque signalé par
l'utilisateur : "clavier" (keydown) ne se déclenche qu'une fois par
appui, insuffisant pour faire avancer un personnage en continu tant
qu'une touche reste enfoncée. Voir runScreenHeldKeyTriggers()
(static/js/play/triggers.js) et TRIGGER_EVENTS (screens/flow/constants.py)."""
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
assert "touche_maintenue" in dict(screens.TRIGGER_EVENTS_2D)
assert "touche_relachee" in dict(screens.TRIGGER_EVENTS_2D)
resp = client.post(
f"/game/{slug}/screens/{screen_id}/flow/nodes/add",
data=json.dumps({"node_type": "trigger", "trigger_event": "touche_maintenue", "trigger_key": "ArrowRight"}),
content_type="application/json",
)
assert resp.status_code == 200
node = screens.get_flow_node(slug, resp.get_json()["id"])
assert node["trigger_event"] == "touche_maintenue"
assert node["trigger_key"] == "ArrowRight"
def test_flow_node_collision_condition_targets_scene_objects(client, tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_a = screens.add_scene_object(slug, screen_id)
obj_b = screens.add_scene_object(slug, screen_id, kind="decor")
resp = client.post(
f"/game/{slug}/screens/{screen_id}/flow/nodes/add",
data=json.dumps({
"node_type": "condition", "cond_source": "collision",
"cond_element_a": obj_a, "cond_element_b": obj_b,
}),
content_type="application/json",
)
assert resp.status_code == 200
node = screens.get_flow_node(slug, resp.get_json()["id"])
assert node["cond_element_a"] == obj_a
assert node["cond_element_b"] == obj_b
def test_play_payload_renders_scene_objects_for_jeu2d_game(client, tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
screens.add_scene_object(slug, screen_id, forge_character="robot")
payload = screens.full_game_payload(slug)
scene_payload = next(s for s in payload["screens"] if s["id"] == screen_id)
assert len(scene_payload["elements"]) == 1
assert "/static/characters/robot/idle.png" in scene_payload["elements"][0]["rendered_html"]
assert any(payload["personnage_animations"].values())
def test_play_html_renders_fixed_size_scene(client, tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
screens.add_scene_object(slug, screen_id, forge_character="zombie")
html = client.get(f"/game/{slug}/play").get_data(as_text=True)
assert "playScene" in html
assert "/static/characters/zombie/idle.png" in html
assert f"width:{db.game_meta(slug)['name'] and 960}px" in html or "width:960px" in html
def test_render_scene_object_carries_data_anim_target(client, tmp_game_slug_cleanup):
"""Bug remonté par l'utilisateur : "les animations ne se jouent pas
dans l'aperçu" — applyAnimationClip() (static/js/play/screens.js) et
animation-timeline.js sélectionnent TOUJOURS leur cible via
[data-anim-target=...], jamais data-element-id/data-object-id. Sans cet
attribut sur l'objet de scène, AUCUN clip de Timeline (sprite, Animate.css
ou personnalisé) ne trouvait jamais sa cible."""
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id)
html = screens.render_scene_object(screens.get_scene_object(slug, obj_id))
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
Timeline sur un objet de scène (_scene_objects, un id space distinct)
faisait échouer l'insertion avec "FOREIGN KEY constraint failed"."""
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id)
resp = client.post(
f"/game/{slug}/screens/{screen_id}/animations/clips/add",
data=json.dumps({"element_id": obj_id, "kind": "animate_css", "animate_name": "fadeIn"}),
content_type="application/json",
)
assert resp.status_code == 200
clip = resp.get_json()
assert clip["element_id"] == obj_id
def test_deleting_scene_object_cleans_up_its_animation_clips(client, tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id)
screens.add_animation_clip(slug, screen_id, obj_id, kind="animate_css", animate_name="fadeIn")
screens.delete_scene_object(slug, obj_id)
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"
(voir static/style.css .sceneCameraFrame) ne s'affiche pas — inutile
tant que rien ne dépasse."""
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 'id="canvas" style="width:960px; height:540px;' in html
assert "sceneCameraFrame" not in html
# "Caméra (px)" : TOUJOURS modifiable, même sans fond qui dépasse
# (demande explicite : "l'écran de l'éditeur a une taille fixe, la
# caméra a la même taille, c'est tout" — un réglage direct, pas
# seulement suggéré automatiquement).
assert 'id="scene-camera-w" class="input is-small" style="width:80px;" min="1" value="960"' in html
assert 'id="scene-camera-h" class="input is-small" style="width:80px;" min="1" value="540"' in html
def test_scene_edit_view_expands_world_for_an_oversized_fond(client, tmp_game_slug_cleanup):
"""Bug corrigé (glisser-déposer figé à (0,0) pour un objet plus grand
que la scène, voir static/js/scenes/scene-editor.js) : le canevas de
l'éditeur doit maintenant afficher tout le "fond", pas seulement la
scène nominale (960×540) qu'il dépasse — sinon la partie qui dépasse
resterait invisible/injoignable à la souris malgré le fix du glisser."""
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
fond_id = screens.add_scene_object(slug, screen_id, kind="fond")
screens.update_scene_object_geometry(slug, fond_id, x=0, y=0, width=1920, height=1080)
html = client.get(f"/game/{slug}/screens/{screen_id}/edit").get_data(as_text=True)
assert 'id="canvas" style="width:1920px; height:1080px;' in html
# Le repère de la caméra marque toujours les bornes de la scène NOMINALE (960×540),
# même si le canevas affiché est désormais bien plus grand.
assert 'class="sceneCameraFrame" style="width:960px; height:540px;' in html
def test_scene_edit_view_world_bounds_are_used_for_drag_clamping(client, tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
fond_id = screens.add_scene_object(slug, screen_id, kind="fond")
screens.update_scene_object_geometry(slug, fond_id, x=0, y=0, width=1920, height=1080)
html = client.get(f"/game/{slug}/screens/{screen_id}/edit").get_data(as_text=True)
assert "var SCENE_WIDTH = 1920;" in html
assert "var SCENE_HEIGHT = 1080;" in html
def test_set_scene_size_updates_screen_dimensions(tmp_game_slug_cleanup):
""""Caméra (px)" (voir templates/scene_edit.html) : jusqu'ici
scene_width/scene_height n'étaient fixées qu'à la création de
l'écran, jamais modifiables ensuite."""
slug = tmp_game_slug_cleanup(db.create_game("pytest_scene_size"))
screen_id = screens.create_screen(slug, "Scène 1", kind="jeu_2d")
screens.set_scene_size(slug, screen_id, 1920, 1080)
screen = screens.get_screen(slug, screen_id)
assert screen["scene_width"] == 1920
assert screen["scene_height"] == 1080
def test_screen_set_scene_size_route_persists_and_reflects_in_editor(client, tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
resp = client.post(
f"/game/{slug}/screens/{screen_id}/scene-size",
data=json.dumps({"width": 1920, "height": 1080}), content_type="application/json",
)
assert resp.get_json() == {"ok": True}
html = client.get(f"/game/{slug}/screens/{screen_id}/edit").get_data(as_text=True)
assert 'id="canvas" style="width:1920px; height:1080px;' in html
# La scène nominale == le monde désormais -> plus de repère caméra.
assert "sceneCameraFrame" not in html
# Les champs "Caméra (px)" restent TOUJOURS visibles (pas conditionnels),
# pré-remplis avec la nouvelle taille.
assert 'id="scene-camera-w" class="input is-small" style="width:80px;" min="1" value="1920"' in html
assert 'id="scene-camera-h" class="input is-small" style="width:80px;" min="1" value="1080"' in html
def test_screen_set_scene_size_route_rejects_invalid_dimensions(client, tmp_game_slug_cleanup):
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
resp = client.post(
f"/game/{slug}/screens/{screen_id}/scene-size",
data=json.dumps({"width": "oops", "height": 540}), content_type="application/json",
)
assert resp.status_code == 400